Image sequence
A pinned canvas that scrubs through a numbered image sequence as you scroll.
Installs gsap from npm, under GSAP's own licence. No GSAP code ships in this registry.
npx shadcn@latest add https://ui.goodtabs.co/r/image-sequence.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/image-sequence
A 48 frame turntable. Scroll to turn it, and the ruler tracks the frame you are on.
Usage
"use client"
import { ImageSequence } from "@/components/ui/image-sequence"
<ImageSequence
frames={48}
src={(i) => `/turntable/${String(i).padStart(2, "0")}.jpg`}
stillFrame={24}
alt="The product turning through a full circle."
>
One full turn.
</ImageSequence>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| frames | number | How many frames the sequence has, required. | |
| src | (index: number) => string | Turns a zero-based frame index into its URL, required. | |
| alt | string | Describes the sequence, required. Goes on the real image. | |
| stillFrame | number | 0 | The zero-based frame shown on phones, under reduced motion and with no script. |
| pace | number | 32 | Scroll distance per frame, in pixels. |
| children | ReactNode | The caption under the image. |
Source
image-sequence/image-sequence.tsx
"use client";
import * as React from "react";
import { useGSAP } from "@gsap/react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { cn } from "@/lib/utils";
gsap.registerPlugin(useGSAP, ScrollTrigger);
const STAGE = "(min-width: 768px) and (prefers-reduced-motion: no-preference)";
const MAX_PIXEL_RATIO = 2;
const PARALLEL_LOADS = 6;
/** Coarse to fine: the stride halves each pass, so the whole scrub is covered long before every frame lands. */
function loadOrder(frames: number) {
const order: number[] = [0];
for (let stride = 2 ** Math.floor(Math.log2(frames)); stride >= 1; stride /= 2) {
for (let index = 0; index < frames; index += stride) {
if (!order.includes(index)) order.push(index);
}
}
return order;
}
/** The loaded frame closest to the one asked for, so a fast scrub never paints a gap. */
function nearestLoaded(images: (HTMLImageElement | undefined)[], index: number) {
for (let distance = 0; distance < images.length; distance++) {
const image = images[index - distance] ?? images[index + distance];
if (image) return image;
}
return undefined;
}
type ImageSequenceProps = Omit<React.ComponentProps<"figure">, "src"> & {
/** How many frames the sequence has. */
frames: number;
/** Turns a zero-based frame index into its URL. */
src: (index: number) => string;
/** Describes the sequence. Goes on the real image. */
alt: string;
/** The zero-based frame shown as a plain image on phones, under reduced motion and with no script. */
stillFrame?: number;
/** Scroll distance per frame, in pixels. */
pace?: number;
};
/**
* A pinned 2D canvas that scrubs through a numbered image sequence as you scroll. The real image is
* always in the markup underneath, so phones, reduced motion and no script all get one good still.
*/
function ImageSequence({
frames,
src,
alt,
stillFrame = 0,
pace = 32,
className,
children,
...props
}: ImageSequenceProps) {
const root = React.useRef<HTMLElement>(null);
const canvas = React.useRef<HTMLCanvasElement>(null);
const source = React.useRef(src);
React.useEffect(() => {
source.current = src;
}, [src]);
useGSAP(
() => {
const media = gsap.matchMedia();
media.add(STAGE, () => {
const element = canvas.current;
const context = element?.getContext("2d");
if (!element || !context) return;
const images = new Array<HTMLImageElement | undefined>(frames);
const queue = loadOrder(frames);
const playhead = { frame: 0 };
const readout = root.current?.querySelector("[data-slot=image-sequence-readout]");
const moveHead = gsap.quickSetter("[data-slot=image-sequence-head]", "xPercent");
const pad = String(frames).length;
let painted: HTMLImageElement | undefined;
let cancelled = false;
gsap.set("[data-slot=image-sequence-layer]", { opacity: 0 });
const paint = (force = false) => {
const image = nearestLoaded(images, Math.round(playhead.frame));
if (!image || (image === painted && !force)) return;
// Cover fit: scale until both sides fill the canvas, then crop the overflow evenly.
const { width, height } = element;
const scale = Math.max(width / image.naturalWidth, height / image.naturalHeight);
const cropWidth = width / scale;
const cropHeight = height / scale;
context.drawImage(
image,
(image.naturalWidth - cropWidth) / 2,
(image.naturalHeight - cropHeight) / 2,
cropWidth,
cropHeight,
0,
0,
width,
height,
);
if (!painted) {
gsap.to("[data-slot=image-sequence-layer]", { opacity: 1, duration: 0.3, ease: "power2.out" });
}
painted = image;
};
const resize = () => {
const ratio = Math.min(window.devicePixelRatio, MAX_PIXEL_RATIO);
element.width = Math.round(element.clientWidth * ratio);
element.height = Math.round(element.clientHeight * ratio);
paint(true);
};
const loadNext = () => {
const index = queue.shift();
if (index === undefined || cancelled) return;
const image = new Image();
image.src = source.current(index);
image
.decode()
.then(
() => {
if (cancelled) return;
images[index] = image;
paint();
},
() => undefined,
)
.finally(loadNext);
};
const observer = new ResizeObserver(resize);
observer.observe(element);
resize();
gsap.to(playhead, {
frame: frames - 1,
ease: "none",
scrollTrigger: {
trigger: root.current,
pin: true,
start: "top top",
end: () => `+=${(frames - 1) * pace}`,
scrub: 0.4,
},
onUpdate: () => {
paint();
moveHead((playhead.frame / (frames - 1)) * 100);
if (readout) {
const current = String(Math.round(playhead.frame) + 1).padStart(pad, "0");
if (readout.textContent !== current) readout.textContent = current;
}
},
});
// Loading starts one screen early, so nothing downloads for a reader who never gets here.
ScrollTrigger.create({
trigger: root.current,
start: "top bottom+=100%",
once: true,
onEnter: () => {
for (let slot = 0; slot < PARALLEL_LOADS; slot++) loadNext();
},
});
return () => {
cancelled = true;
observer.disconnect();
};
});
return () => media.revert();
},
{ scope: root, dependencies: [frames, pace] },
);
return (
<figure
ref={root}
data-slot="image-sequence"
className={cn("m-0 flex flex-col gap-3 bg-background p-4 md:h-dvh md:min-h-96 md:p-6", className)}
{...props}
>
<div
data-slot="image-sequence-plate"
className="relative aspect-[8/5] w-full overflow-hidden rounded-lg border bg-muted md:aspect-auto md:min-h-0 md:flex-1"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={src(stillFrame)} alt={alt} className="absolute inset-0 size-full object-cover" />
<div
aria-hidden="true"
data-slot="image-sequence-layer"
className="absolute inset-0 hidden md:motion-safe:not-noscript:block"
>
<canvas ref={canvas} className="block size-full" />
</div>
</div>
<figcaption className="flex min-h-5 items-center gap-6 text-sm text-muted-foreground">
<span className="min-w-0 flex-1">{children}</span>
<span
aria-hidden="true"
className="relative hidden h-3 w-64 shrink-0 text-muted-foreground/50 md:motion-safe:not-noscript:block"
style={{
backgroundImage: `repeating-linear-gradient(to right, currentColor 0 1px, transparent 1px calc(100% / ${Math.max(frames - 1, 1)}))`,
}}
>
<span data-slot="image-sequence-head" className="absolute inset-0 block">
<span className="absolute top-0 left-0 block h-full w-px bg-foreground" />
</span>
</span>
<span aria-hidden="true" className="hidden font-mono text-xs md:motion-safe:not-noscript:block">
<span data-slot="image-sequence-readout" className="text-foreground">
{String(1).padStart(String(frames).length, "0")}
</span>
{` / ${frames}`}
</span>
</figcaption>
</figure>
);
}
export { ImageSequence };
For AI agents
Hand your agent this page, the markdown at /components/image-sequence/prompt.md, or the Copy AI prompt button above.
When to use
- A product turntable or an exploded view rendered to frames
- A short film people should control with scroll
- Once on a landing page, as the moment people stop to play with
When not to use
- For footage with sound or anything over a few hundred frames: use video
- When the frames are heavy and the audience is on slow connections
- Inside a scaled or transformed parent, which breaks ScrollTrigger's measuring
Agent notes
- GSAP installs from npm under GSAP's own licence. No GSAP code ships in this registry
- src is a function, so any page or demo that passes it must be a client component, or the static build fails
- Below 768px, under reduced motion and with no script it is one real img at stillFrame, and no other frame downloads
- Frames load coarse to fine, six at a time, one screen before the piece arrives. A frame that fails to load is skipped and its nearest neighbour shows
- It pins against the window, so it must not sit inside an overflow container or a CSS transform
The maths
- Pin distance is (frames - 1) × pace, so 48 frames at 32px scroll 1504px, a little under two screens at 800px.
- Cover fit scale is max(W / iw, H / ih), and the crop is W / scale by H / scale, centred.
- The canvas buffer is CSS size × min(dpr, 2), so a 3x phone never pays for nine times the pixels.
- Stride halves each pass, 32, 16, 8, 4, 2, 1, so the first 12 of 48 frames already cover the scrub at every fourth step.