shipping/JOG — 03:41 GMT+7/48.3 °C CPU/v 07.0 · build 2026.05.12
lat −7.7956 · lon 110.3695/ntwk · online/open for projects
back to notesnotes2026gsap-image-sequence-scroll
01 of 30previous ←next →
[ essay no. 01 ]craftmay 12, 20265 min1,055 wordsrevision 1live

Cinematic Scroll

Build the Apple-style scroll-driven image sequence. A 3D-rendered animation plays frame-by-frame as you scroll — pure canvas + GSAP ScrollTrigger, no video, no autoplay quirks.

d
devrangga hazza mahiswaracreative engineer · jogja, id
share on 𝕏
01

What we're building

The technique: a <canvas> element sits sticky inside a tall scroll container. A pre-rendered 3D animation is sliced into ~120 JPEG frames. As the user scrolls through the container, GSAP maps the scroll progress to a frame index and paints that frame onto the canvas.

The result looks like a video but gives you full scroll control: go slow, reverse, scrub. No autoplay quirks, no codec dependencies.

What you need before starting:

  • A sequence of numbered JPEG frames (e.g. frame-001.jpgframe-120.jpg). Export from Blender, Cinema 4D, or any 3D software. You can use PNG sequences too — JPEGs are smaller.
  • A Next.js project (App Router). The component works in any React app; only the image path convention differs.
  • GSAP installed.
02

Install GSAP

If you haven't already:

npm install gsap

GSAP's ScrollTrigger plugin is bundled with the main package — no separate install needed.

In Next.js, GSAP must be imported inside a client component ("use client"). It cannot be used in Server Components because it accesses window.

03

Prepare your image frames

Place your frames under public/sequence/ with zero-padded names:

public/
  sequence/
    frame-001.jpg
    frame-002.jpg
    ...
    frame-120.jpg

The total frame count controls the resolution of the effect. 60–90 frames is usable; 120+ is cinematic. Lower is fine for short reveals.

Write a utility to generate the URL array:

// lib/sequence.ts
export function buildFrameUrls(
  basePath: string,
  totalFrames: number,
  digits = 3
): string[] {
  return Array.from({ length: totalFrames }, (_, i) =>
    `${basePath}/frame-${String(i + 1).padStart(digits, "0")}.jpg`
  );
}
04

Preload all frames

The critical part. If you paint frames on-demand, you get flicker — the browser hasn't decoded the image yet. Preload everything before the user scrolls into view.

// lib/preload-sequence.ts
export function preloadSequence(
  urls: string[],
  onProgress?: (loaded: number, total: number) => void
): Promise<HTMLImageElement[]> {
  let loaded = 0;
 
  return Promise.all(
    urls.map(
      (src) =>
        new Promise<HTMLImageElement>((resolve, reject) => {
          const img = new Image();
          img.onload = () => {
            loaded++;
            onProgress?.(loaded, urls.length);
            resolve(img);
          };
          img.onerror = reject;
          img.src = src;
        })
    )
  );
}

Call this inside a useEffect so it only runs in the browser.

05

Build the canvas component

This is the core component. It:

  1. Creates a sticky canvas inside a tall scroll container
  2. Preloads frames when the component mounts
  3. Uses GSAP ScrollTrigger to scrub the frame index
// components/ImageSequence.tsx
"use client";
 
import { useEffect, useRef, useState } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { buildFrameUrls } from "@/lib/sequence";
import { preloadSequence } from "@/lib/preload-sequence";
 
gsap.registerPlugin(ScrollTrigger);
 
type Props = {
  basePath: string;
  totalFrames: number;
  /** How many viewport heights tall the scroll container should be */
  scrollHeight?: number;
};
 
export function ImageSequence({
  basePath,
  totalFrames,
  scrollHeight = 5,
}: Props) {
  const containerRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const framesRef = useRef<HTMLImageElement[]>([]);
  const frameIndexRef = useRef({ value: 0 });
  const [ready, setReady] = useState(false);
 
  useEffect(() => {
    const urls = buildFrameUrls(basePath, totalFrames);
    preloadSequence(urls).then((imgs) => {
      framesRef.current = imgs;
      setReady(true);
    });
  }, [basePath, totalFrames]);
 
  const paintFrame = (index: number) => {
    const canvas = canvasRef.current;
    const frames = framesRef.current;
    if (!canvas || frames.length === 0) return;
 
    const img = frames[Math.round(index)];
    if (!img) return;
 
    const ctx = canvas.getContext("2d");
    if (!ctx) return;
 
    const scale = Math.max(
      canvas.width / img.naturalWidth,
      canvas.height / img.naturalHeight
    );
    const x = (canvas.width - img.naturalWidth * scale) / 2;
    const y = (canvas.height - img.naturalHeight * scale) / 2;
 
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    ctx.drawImage(img, x, y, img.naturalWidth * scale, img.naturalHeight * scale);
  };
 
  useEffect(() => {
    if (!ready || !containerRef.current) return;
 
    paintFrame(0);
 
    const tween = gsap.to(frameIndexRef.current, {
      value: totalFrames - 1,
      ease: "none",
      scrollTrigger: {
        trigger: containerRef.current,
        start: "top top",
        end: "bottom bottom",
        scrub: 0.5,
        onUpdate: (self) => {
          paintFrame(Math.round(self.progress * (totalFrames - 1)));
        },
      },
    });
 
    return () => {
      tween.kill();
      ScrollTrigger.getAll().forEach((t) => t.kill());
    };
  }, [ready, totalFrames]);
 
  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
 
    const resize = () => {
      const rect = canvas.getBoundingClientRect();
      canvas.width = rect.width * window.devicePixelRatio;
      canvas.height = rect.height * window.devicePixelRatio;
      const ctx = canvas.getContext("2d");
      if (ctx) ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
      paintFrame(frameIndexRef.current.value);
    };
 
    resize();
    window.addEventListener("resize", resize);
    return () => window.removeEventListener("resize", resize);
  }, [ready]);
 
  return (
    <div
      ref={containerRef}
      style={{ height: `${scrollHeight * 100}vh` }}
      className="relative"
    >
      <div className="sticky top-0 h-screen w-full overflow-hidden bg-black">
        {!ready && (
          <div className="absolute inset-0 flex items-center justify-center">
            <span className="font-mono text-xs text-white/40 uppercase tracking-widest">
              loading…
            </span>
          </div>
        )}
        <canvas
          ref={canvasRef}
          className="h-full w-full"
          style={{ opacity: ready ? 1 : 0, transition: "opacity 0.4s" }}
        />
      </div>
    </div>
  );
}
06

Use it on a page

Drop the component onto any page. The scrollHeight prop controls how many viewports tall the scroll area is — more height = slower, more deliberate scrub.

import { ImageSequence } from "@/components/ImageSequence";
 
export default function Page() {
  return (
    <main>
      <section className="h-screen flex items-center justify-center">
        <h1 className="text-4xl font-bold">scroll down</h1>
      </section>
 
      <ImageSequence
        basePath="/sequence"
        totalFrames={120}
        scrollHeight={6}
      />
 
      <section className="h-screen flex items-center justify-center">
        <p>content after the sequence</p>
      </section>
    </main>
  );
}
07

Add a text overlay (optional)

The sticky canvas is just a div — absolutely-position text over it. This is how Apple animates copy in sync with the 3D model.

<div className="sticky top-0 h-screen w-full overflow-hidden bg-black relative">
  <canvas ref={canvasRef} className="h-full w-full absolute inset-0" />
 
  <div
    ref={textRef}
    className="absolute inset-0 flex items-end justify-start p-16 pointer-events-none"
  >
    <p className="font-display text-[64px] text-white leading-none tracking-tight max-w-[600px]">
      built from light.
    </p>
  </div>
</div>

Then animate textRef based on scroll progress:

gsap.fromTo(
  textRef.current,
  { opacity: 0, y: 30 },
  {
    opacity: 1,
    y: 0,
    scrollTrigger: {
      trigger: containerRef.current,
      start: "20% top",
      end: "40% top",
      scrub: true,
    },
  }
);
08

Performance notes

Frame count vs file size. 120 JPEGs at 1280×720 × ~40 KB = ~5 MB total. Fine on fast connections. For mobile, serve a lower-resolution sequence (/sequence-mobile/) and swap based on window.innerWidth.

scrub: 0.5 smooths the frame advance. Without it, fast scroll gestures snap frames. Higher values = more lag but smoother feel.

DPR scaling. The canvas resize handler scales by devicePixelRatio so Retina displays stay sharp. Clamp to 2 for performance: Math.min(window.devicePixelRatio, 2).

Cleanup. The return () => { ... } in useEffect kills triggers on unmount. Without this, hot-reload in development stacks duplicate triggers.

— end of essay · published may 12, 2026 · 1,055 words · 5 min
[ if this moved you ]

keep reading.

three essays in the same key · pick one