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.jpg→frame-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.
Install GSAP
If you haven't already:
npm install gsapGSAP'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.
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`
);
}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.
Build the canvas component
This is the core component. It:
- Creates a sticky canvas inside a tall scroll container
- Preloads frames when the component mounts
- 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>
);
}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>
);
}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,
},
}
);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.
