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.

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>
  );
}