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…
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
"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
Conflicts with
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.