Skip to content
goodtabs UI
BrowseBrowse components

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

PropTypeDefaultDescription
framesnumberHow many frames the sequence has, required.
src(index: number) => stringTurns a zero-based frame index into its URL, required.
altstringDescribes the sequence, required. Goes on the real image.
stillFramenumber0The zero-based frame shown on phones, under reduced motion and with no script.
pacenumber32Scroll distance per frame, in pixels.
childrenReactNodeThe 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.