Rejouice — loading0

Scroll-scrubbed sequence

A pinned section plays a canvas image sequence frame by frame as you scroll, so the scroll wheel becomes a transport control.

Grade: BRuntimes: gsap · canvasStatus: live
Loading demo…
Scroll-scrubbed sequence — scroll inside the frame to drive itOpen standalone ↗

Intent

The highest-value move in the direction, and the one people remember. Handing the playhead to the scroll position makes the viewer feel like they are operating the product rather than watching a video of it. The pipeline behind it is a bespoke 3D render exported to frames — the technique is cheap, the asset is where the money goes.

Implementation

The shipped component. Preload and first draw happen before the reduced-motion check, so the static path still renders a picture.

Dependencies: gsap@^3.13, @gsap/react@^2

next-tailwind-motion
"use client";

import { useRef } from "react";
import { gsap, useGSAP, prefersReduced } from "@/lib/gsap";

export function ScrubSequence({ basePath, frameCount, width = 1280, height = 720 }: {
  basePath: string; frameCount: number; width?: number; height?: number;
}) {
  const root = useRef<HTMLDivElement>(null);
  const canvas = useRef<HTMLCanvasElement>(null);

  useGSAP(() => {
    const ctx = canvas.current?.getContext("2d");
    if (!ctx) return;

    const frames = Array.from({ length: frameCount }, (_, i) => {
      const img = new Image();
      img.src = `${basePath}/frame_${String(i).padStart(3, "0")}.png`;
      return img;
    });

    const draw = (i: number) => {
      const img = frames[Math.round(i)];
      if (img?.complete) ctx.drawImage(img, 0, 0, width, height);
    };

    // Draw before the reduced-motion bail, so that path still shows a frame.
    frames[0].onload = () => draw(0);
    if (prefersReduced()) return;

    const state = { i: 0 };
    const tween = gsap.to(state, {
      i: frameCount - 1,
      snap: "i",
      ease: "none",
      onUpdate: () => draw(state.i),
      scrollTrigger: {
        trigger: root.current, start: "top top", end: "+=2600", scrub: 0.5, pin: true,
      },
    });

    return () => tween.kill();
  }, { scope: root });

  return (
    <div ref={root} className="grid min-h-svh place-items-center">
      <canvas ref={canvas} width={width} height={height} className="w-full max-w-4xl" />
    </div>
  );
}

Use it when

  • Exactly one product or hero moment per site. It is the crescendo, not the grammar.
  • When the subject rewards being turned, opened, or assembled.

Avoid it when

  • More than once on a page — the second one is just a long scroll.
  • When you only have stock footage. Without a purpose-shot sequence it reads as a scrubby video.
  • On pages where users arrive to complete a task.

Accessibility

  • Under reduced motion draw frame 0 and do not pin — the section still communicates, it just does not move.
  • A canvas is invisible to assistive tech. Put the information the sequence conveys in adjacent text, never only in the frames.
  • Pinning removes the normal relationship between scroll distance and progress; keep the pin under about three viewport heights or it reads as a stuck page.

Performance

  • Canvas frames, not `video.currentTime`. Seeking a video is janky and inconsistent across browsers; drawing a decoded image is deterministic.
  • Preload every frame before arming the trigger, and draw frame 0 immediately so the section is never blank.
  • Keep the count modest and the frames compressed — this is bandwidth, and it is the real cost of the pattern.
  • `snap` the tweened index to whole numbers so you never request a fractional frame.

Knobs

frameCount
How many frames in the sequence. Default: 24
pinLength
Scroll distance the pin lasts. Default: +=2600
scrub
Smoothing between scroll and playhead. Default: 0.5

Composition

Instruction for Claude Code

Build a scroll-scrubbed canvas sequence: preload numbered frames into Image objects, draw the current one to a canvas, and tween a frame index from 0 to count-1 with snap:"i" and ease:"none" on a ScrollTrigger with pin:true, scrub:0.5 and end:"+=2600". Use canvas frames rather than video.currentTime — seeking a video is janky and inconsistent across browsers. Draw frame 0 as soon as it loads and before the reduced-motion check so the static path still shows a picture; under reduced motion skip the pin entirely. Keep the pin under about three viewport heights and put whatever the sequence communicates in adjacent text, because a canvas is invisible to assistive tech.