Wave reel
A looping horizontal film strip whose planes ripple like cloth in proportion to how fast you scrub it, then flatten as it comes to rest.
Loading preview…
Show code
"use client";
import { Canvas, useFrame, useLoader, useThree } from "@react-three/fiber";
import { Suspense, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import * as THREE from "three";
import { cn } from "@/lib/utils";
// A horizontal film strip of images that loops forever. It always has content
// past both edges, so both edges always fade (a fixed 24px mask, the same
// size as the registry's scroll fade, with no scroll position to track).
// Scroll, drag or arrow keys move a target; the strip lerps toward it, and the speed of that
// chase bends every plane with a sine wave in its vertex shader, so a fast
// scrub ripples the reel like cloth and it flattens as it comes to rest.
export type ReelItem = {
id: string;
src: string;
/** Width / height. */
aspect?: number;
title?: string;
};
export type WaveReelParams = {
/** World units added per wheel pixel, relative to 0.01. */
scrollSpeed: number;
/** Fraction of the remaining distance covered per 60Hz frame. */
lerp: number;
waveFrequency: number;
waveAmplitude: number;
planeHeight: number;
planeGap: number;
};
export const WAVE_REEL_DEFAULTS: WaveReelParams = {
scrollSpeed: 0.01,
lerp: 0.01,
waveFrequency: 1.1,
waveAmplitude: 0.003,
planeHeight: 2,
planeGap: 0.05,
};
/** The strip repeats until it is at least this wide, so the loop never shows a gap. */
const MIN_STRIP_WIDTH = 24;
const vertexShader = /* glsl */ `
uniform float uVelocity;
uniform float uWaveFreq;
uniform float uWaveAmp;
varying vec2 vUv;
void main() {
vUv = uv;
vec4 worldPos = modelMatrix * vec4(position, 1.0);
float wave = sin(worldPos.x * uWaveFreq) * uVelocity * uWaveAmp;
vec3 pos = position;
pos.y += wave;
pos.z += wave;
gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(pos, 1.0);
}
`;
const fragmentShader = /* glsl */ `
uniform sampler2D uTexture;
varying vec2 vUv;
void main() {
gl_FragColor = texture2D(uTexture, vUv);
// Encode for the sRGB canvas; without it every image showed darker than its source.
#include <colorspace_fragment>
}
`;
const mod = (a: number, n: number) => ((a % n) + n) % n;
/** Frame-rate independent version of `x += (target - x) * k` at 60Hz. */
const lerpAlpha = (k: number, dtMs: number) => 1 - Math.pow(1 - k, dtMs / 16.67);
type Plane = { item: ReelItem; key: string; baseX: number; w: number };
type ReelMesh = THREE.Mesh<THREE.PlaneGeometry, THREE.ShaderMaterial>;
function layout(items: ReelItem[], h: number, gap: number) {
const planes: Plane[] = [];
let x = 0;
for (let rep = 0; items.length && (rep === 0 || x < MIN_STRIP_WIDTH); rep++) {
for (const item of items) {
const w = h * (item.aspect ?? 1);
planes.push({ item, key: `${rep}:${item.id}`, baseX: x + w / 2, w });
x += w + gap;
}
}
return { planes, total: x };
}
function usePlaceholder() {
const tex = useMemo(() => {
const t = new THREE.DataTexture(new Uint8Array([200, 200, 200, 255]), 1, 1);
t.needsUpdate = true;
return t;
}, []);
useEffect(() => () => tex.dispose(), [tex]);
return tex;
}
function PlaneMesh({
plane,
height,
texture,
register,
}: {
plane: Plane;
height: number;
texture: THREE.Texture;
register: (key: string, mesh: ReelMesh | null) => void;
}) {
const materialRef = useRef<THREE.ShaderMaterial>(null);
// Built once; texture swaps go through the material ref, not a rebuild.
const [uniforms] = useState(() => ({
uTexture: { value: texture },
uVelocity: { value: 0 },
uWaveFreq: { value: WAVE_REEL_DEFAULTS.waveFrequency },
uWaveAmp: { value: WAVE_REEL_DEFAULTS.waveAmplitude },
}));
useEffect(() => {
if (materialRef.current) materialRef.current.uniforms.uTexture.value = texture;
}, [texture]);
return (
<mesh
ref={(m: ReelMesh | null) => {
if (m) m.userData = { baseX: plane.baseX, w: plane.w, item: plane.item };
register(plane.key, m);
}}
position={[plane.baseX, 0, 0]}
>
<planeGeometry args={[plane.w, height, 30, 30]} />
<shaderMaterial ref={materialRef} uniforms={uniforms} vertexShader={vertexShader} fragmentShader={fragmentShader} />
</mesh>
);
}
function TexturedPlane(props: { plane: Plane; height: number; register: (key: string, mesh: ReelMesh | null) => void }) {
const loaded = useLoader(THREE.TextureLoader, props.plane.item.src);
const texture = useMemo(() => {
const t = loaded.clone();
t.colorSpace = THREE.SRGBColorSpace;
t.needsUpdate = true;
return t;
}, [loaded]);
useEffect(() => () => texture.dispose(), [texture]);
return <PlaneMesh {...props} texture={texture} />;
}
function FallbackPlane(props: { plane: Plane; height: number; register: (key: string, mesh: ReelMesh | null) => void }) {
return <PlaneMesh {...props} texture={usePlaceholder()} />;
}
type Input = {
target: number;
arrow: number;
paused: boolean;
reduced: boolean;
/** While dragging, the strip sits exactly on the target: no chase, no lag. */
direct: boolean;
};
/** What the scene hands back to the DOM layer for picking and 1:1 drags. */
type Bridge = {
pick: (x: number, y: number) => ReelItem | undefined;
/** World units per on-screen pixel, so a drag tracks the hand exactly. */
unitsPerPx: () => number;
/** The target value that matches where the strip is drawn right now. */
position: () => number;
};
function Reel({
planes,
total,
params,
input,
onBridge,
background,
}: {
planes: Plane[];
total: number;
params: WaveReelParams;
input: RefObject<Input>;
onBridge: (bridge: Bridge | null) => void;
background: string;
}) {
const { camera, gl } = useThree();
const meshes = useRef(new Map<string, ReelMesh>());
const scroll = useRef(0);
const last = useRef(0);
/** Distance travelled by held arrow keys, added on top of the input target. */
const arrowShift = useRef(0);
const raycaster = useMemo(() => new THREE.Raycaster(), []);
const register = useMemo(
() => (key: string, mesh: ReelMesh | null) => {
if (mesh) meshes.current.set(key, mesh);
else meshes.current.delete(key);
},
[],
);
useEffect(() => {
onBridge({
pick: (cx, cy) => {
const rect = gl.domElement.getBoundingClientRect();
if (!rect.width || !rect.height) return undefined;
raycaster.setFromCamera(
new THREE.Vector2(((cx - rect.left) / rect.width) * 2 - 1, -((cy - rect.top) / rect.height) * 2 + 1),
camera,
);
return raycaster.intersectObjects([...meshes.current.values()], false)[0]?.object.userData.item as
| ReelItem
| undefined;
},
unitsPerPx: () => {
const cam = camera as THREE.PerspectiveCamera;
const viewW = 2 * Math.tan((cam.fov * Math.PI) / 360) * cam.position.z * cam.aspect;
// Screen rect, so a CSS-scaled reel still tracks the pointer exactly.
return viewW / Math.max(1, gl.domElement.getBoundingClientRect().width);
},
position: () => scroll.current - arrowShift.current,
});
return () => onBridge(null);
}, [camera, gl, raycaster, onBridge]);
useFrame((_, delta) => {
const inp = input.current;
if (!inp || planes.length === 0) return;
const dt = Math.min(delta * 1000, 50);
if (!inp.paused) arrowShift.current += inp.arrow * dt * params.scrollSpeed;
// Reduced motion: a quick, flat chase instead of a slow, rippling one.
const k = inp.reduced ? Math.max(params.lerp, 0.25) : params.lerp;
const goal = inp.target + arrowShift.current;
scroll.current = inp.direct ? goal : scroll.current + (goal - scroll.current) * lerpAlpha(k, dt);
const velocity = (scroll.current - last.current) / Math.max(dt / 1000, 1e-4);
last.current = scroll.current;
const cam = camera as THREE.PerspectiveCamera;
const viewH = 2 * Math.tan((cam.fov * Math.PI) / 360) * cam.position.z;
const half = (viewH * cam.aspect) / 2;
const amp = inp.reduced ? 0 : params.waveAmplitude;
for (const mesh of meshes.current.values()) {
const { baseX, w } = mesh.userData as { baseX: number; w: number };
mesh.position.x = mod(baseX - scroll.current + half + w, total) - half - w;
const u = mesh.material.uniforms;
u.uVelocity.value += (velocity - u.uVelocity.value) * lerpAlpha(k, dt);
u.uWaveFreq.value = params.waveFrequency;
u.uWaveAmp.value = amp;
}
});
return (
<>
<color attach="background" args={[background]} />
{planes.map((plane) => (
<Suspense key={plane.key} fallback={<FallbackPlane plane={plane} height={params.planeHeight} register={register} />}>
{plane.item.src ? (
<TexturedPlane plane={plane} height={params.planeHeight} register={register} />
) : (
<FallbackPlane plane={plane} height={params.planeHeight} register={register} />
)}
</Suspense>
))}
</>
);
}
export function WaveReel({
items,
params,
onItemOpen,
paused = false,
wheel = "always",
background = "#ffffff",
className,
label = "Image reel",
}: {
items: ReelItem[];
params?: Partial<WaveReelParams>;
onItemOpen?: (item: ReelItem) => void;
/** Ignore wheel, drag and keys (e.g. while a menu covers the reel). */
paused?: boolean;
/**
* "always" takes every wheel event over the reel (full-screen use).
* "engaged" lets the page scroll until the reel is clicked or focused.
*/
wheel?: "always" | "engaged";
/** Clear colour, any CSS colour three.js can parse. */
background?: string;
className?: string;
label?: string;
}) {
const P = useMemo(() => ({ ...WAVE_REEL_DEFAULTS, ...params }), [params]);
const { planes, total } = useMemo(() => layout(items, P.planeHeight, P.planeGap), [items, P.planeHeight, P.planeGap]);
const rootRef = useRef<HTMLDivElement>(null);
const input = useRef<Input>({ target: 0, arrow: 0, paused, reduced: false, direct: false });
const bridgeRef = useRef<Bridge | null>(null);
const setBridge = useCallback((bridge: Bridge | null) => {
bridgeRef.current = bridge;
}, []);
const drag = useRef<{
id: number;
lastX: number;
lastT: number;
startX: number;
startY: number;
moved: boolean;
/** World units per second at the last move. */
v: number;
} | null>(null);
const [engaged, setEngaged] = useState(wheel === "always");
const engagedRef = useRef(engaged);
const [visible, setVisible] = useState(false);
// Device pixels per CSS pixel actually on screen (a scaled-down preview
// shouldn't render at full native resolution).
const [dpr, setDpr] = useState(1);
useEffect(() => {
input.current.paused = paused;
engagedRef.current = wheel === "always" || engaged;
}, [paused, wheel, engaged]);
useEffect(() => {
const motion = window.matchMedia("(prefers-reduced-motion: reduce)");
const sync = () => {
input.current.reduced = motion.matches;
};
sync();
motion.addEventListener("change", sync);
return () => motion.removeEventListener("change", sync);
}, []);
// Wheel on the reel only (production listened on window), and no frames
// while the reel is offscreen.
useEffect(() => {
const el = rootRef.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
if (input.current.paused || !engagedRef.current) return;
e.preventDefault();
const d = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
input.current.target += d * 0.002 * (P.scrollSpeed / 0.01);
};
el.addEventListener("wheel", onWheel, { passive: false });
const io = new IntersectionObserver(([entry]) => setVisible(entry?.isIntersecting ?? false));
io.observe(el);
const syncDpr = () => {
const k = el.clientWidth ? Math.min(1, el.getBoundingClientRect().width / el.clientWidth) : 1;
setDpr(Math.max(0.5, Math.min(window.devicePixelRatio || 1, 2) * k));
};
syncDpr();
// The preview's CSS scale can settle a frame or two after mount.
const settle = window.setTimeout(syncDpr, 250);
window.addEventListener("resize", syncDpr);
return () => {
el.removeEventListener("wheel", onWheel);
io.disconnect();
window.clearTimeout(settle);
window.removeEventListener("resize", syncDpr);
};
}, [P.scrollSpeed]);
return (
<div
ref={rootRef}
role="group"
aria-roledescription="carousel"
aria-label={`${label}. Drag, scroll or use the arrow keys to move.`}
tabIndex={0}
onFocus={() => setEngaged(true)}
onBlur={() => {
input.current.arrow = 0;
if (wheel === "engaged") setEngaged(false);
}}
onKeyDown={(e) => {
if (paused) return;
if (e.key === "ArrowRight") input.current.arrow = 1;
else if (e.key === "ArrowLeft") input.current.arrow = -1;
else return;
e.preventDefault();
}}
onKeyUp={(e) => {
if ((e.key === "ArrowRight" && input.current.arrow === 1) || (e.key === "ArrowLeft" && input.current.arrow === -1))
input.current.arrow = 0;
}}
onPointerDown={(e) => {
if (paused || (e.pointerType === "mouse" && e.button !== 0)) return;
setEngaged(true);
// Catch the strip where it is drawn, then hold it there under the hand.
const at = bridgeRef.current?.position();
if (at !== undefined) input.current.target = at;
input.current.direct = true;
drag.current = {
id: e.pointerId,
lastX: e.clientX,
lastT: performance.now(),
startX: e.clientX,
startY: e.clientY,
moved: false,
v: 0,
};
e.currentTarget.setPointerCapture(e.pointerId);
}}
onPointerMove={(e) => {
const d = drag.current;
if (!d || d.id !== e.pointerId) return;
if (Math.hypot(e.clientX - d.startX, e.clientY - d.startY) > 8) d.moved = true;
const now = performance.now();
const du = -(e.clientX - d.lastX) * (bridgeRef.current?.unitsPerPx() ?? 0);
input.current.target += du;
d.v = du / Math.max((now - d.lastT) / 1000, 1 / 240);
d.lastX = e.clientX;
d.lastT = now;
}}
onPointerUp={(e) => {
const d = drag.current;
drag.current = null;
input.current.direct = false;
if (!d || d.id !== e.pointerId) return;
// Release momentum: project a short glide from the last velocity.
if (d.moved && performance.now() - d.lastT < 80 && !input.current.reduced) input.current.target += d.v * 0.35;
if (d.moved || !onItemOpen) return;
const item = bridgeRef.current?.pick(e.clientX, e.clientY);
if (item) onItemOpen(item);
}}
onPointerCancel={() => {
drag.current = null;
input.current.direct = false;
}}
onPointerLeave={() => {
if (wheel === "engaged" && !drag.current) setEngaged(false);
}}
className={cn(
"relative isolate cursor-grab touch-pan-y overflow-hidden rounded-xl [mask-image:linear-gradient(to_right,transparent,#000_24px,#000_calc(100%-24px),transparent)] outline-none select-none focus-visible:ring-2 focus-visible:ring-ring active:cursor-grabbing",
className,
)}
style={{ background }}
>
<Canvas
camera={{ position: [0, 0, 4.5], fov: 45, near: 0.1, far: 100 }}
gl={{ alpha: false, antialias: true }}
dpr={dpr}
// Measure layout size, not the transformed box, so a CSS-scaled reel fills its frame.
resize={{ offsetSize: true }}
frameloop={visible ? "always" : "never"}
aria-hidden
>
<Reel planes={planes} total={total} params={P} input={input} onBridge={setBridge} background={background} />
</Canvas>
</div>
);
}