Scroll paints frames
not playback
1,278 still images across nine cinematic sequences, one canvas component, and a tiny bit of math. As you scroll, we compute which frame belongs at this position and paint it. No video tag, no playback, no buffering — just the same trick Apple uses on its product pages.
AI-nativescrollstorytellingthatturnsstillframesintokineticnarrative.
171 individual JPEGs of ink-in-water, hue-rotated to cyan in ffmpeg, painted to a canvas as you scroll. Same engine — different poetry.
What does
a thought look like
when you scroll it?
Two-hundred and forty frames.
One scroll.
Source: a thirty-second slice of a 4K particle visualizer. Extracted at 8 fps, native 2560×1440, served from the same storage.zeeztech.com CDN as everything else on the site. The narrative is what your scroll wheel decides.
Image sequence on a canvas, indexed by scroll
Apple, Stripe, and most of the “scrolling makes the thing move” sites do roughly this. Four steps.
- 01
Bake the animation into still frames
We took 147 frames of a hero animation, named them 0001.jpg…0147.jpg, and pushed them to the schoolhq minio bucket. Frames are tiny JPEGs (~30–50 KB each). 1158×770 source.
- 02
Reserve a tall scroll runway
A wrapper that's 500vh tall holds a child positioned `sticky top-0 h-screen`. While the wrapper is in view the canvas stays pinned, and the page can scroll a long distance without moving anything visually.
- 03
Map scroll position to a frame index
On every scroll, we read the wrapper's `getBoundingClientRect()`, normalize how far we've scrolled into [0, 1], and multiply by the frame count. That index picks one preloaded image.
- 04
Paint that one frame to a canvas
All 147 images are preloaded into Image objects on mount. We `drawImage` only the chosen frame, scaled to cover the viewport. requestAnimationFrame keeps it buttery — no layout, no playback head, no codec.
// the entire idea, distilled
const total = 147;
const frames = Array.from({ length: total }, (_, i) => {
const img = new Image();
img.src = `/airpods/${String(i + 1).padStart(4, "0")}.jpg`;
return img;
});
window.addEventListener("scroll", () => {
const r = wrap.getBoundingClientRect();
const p = -r.top / (wrap.offsetHeight - innerHeight);
const i = Math.min(total - 1, Math.floor(p * total));
ctx.drawImage(frames[i], 0, 0);
});6.5 MB total
147 progressive JPEGs, average ~45 KB each. A single 4K MP4 of the same animation would be 5–8× larger and would buffer instead of scrub.
One drawImage per scroll tick
No layout, no React rerenders inside the hot path. We coalesce scroll events with requestAnimationFrame and skip redraws when the index hasn't changed.
storage.schoolhq.in
The minio bucket fronts a Cloudflare CDN, so the first scroll fills the cache and every subsequent scroll is local. No Apple CDN dependency at runtime.
Anything you can render to PNG, you can scrub on scroll.
Product reveals, exploded views, chip-die fly-throughs, terrain zooms, story beats. The technique scales to anything baked into a numbered frame sequence.
frames © Apple Inc. — used here for technique demonstration only