More Scroll Snippets
Scroll Image Sequence — Free GSAP Canvas Scrub Snippet
Scroll Image Sequence · Scroll · Plain HTML, CSS & JS · Live preview
What's included
Features
About this UI Snippet
Scroll Image Sequence — Scrub a Canvas Frame Animation With the Scrollbar

The scroll image sequence is the technique behind Apple's AirPods and MacBook pages: an animation rendered as individual frames, drawn to a <canvas>, with the scrollbar acting as the playhead. Scroll down and the sequence plays forward frame by frame; scroll up and it runs in reverse. This snippet builds the full pipeline — frame storage, a numeric playhead, and a pinned, scrubbed ScrollTrigger — with GSAP loaded from a CDN.
Frames are pre-rendered once, not drawn per scroll event
The demo generates 80 frames procedurally (an orbiting particle ring with a growing core) and paints each one into its own offscreen document.createElement('canvas') at build time. That mirrors how a production sequence works with exported PNGs: every frame is decoded and rasterized exactly once, up front, so scrolling never triggers image decoding or geometry math. The scroll handler's only job is a single drawImage() blit of an already-painted bitmap — the cheapest possible paint the canvas API offers.
A numeric playhead object is what actually gets tweened
GSAP can't tween a canvas, so the snippet tweens a plain proxy object: gsap.to(playhead, { frame: FRAMES - 1 }). The tween's onUpdate rounds playhead.frame to an integer index and blits that frame. This proxy pattern is the standard way to scrub any non-DOM target — video currentTime, WebGL uniforms, chart data — because ScrollTrigger just needs *some* number to interpolate between its start and end.
Pinning turns scroll distance into a timeline
The stage is pinned with start: 'top top' and end: '+=250%', so the section holds still while two and a half viewport-heights of scrolling map onto the 80 frames — roughly 20px of scroll per frame, enough resolution that the motion reads as continuous. scrub: 0.4 adds a short smoothing window so trackpad flicks glide between frames instead of stuttering, while still snapping to the scrollbar within half a second.
Why Math.round and not floor
The playhead arrives as a float (frame 37.6), and Math.round picks the nearest frame in either direction. Flooring would bias the sequence backward — you'd only see frame N once you'd fully passed it — which makes reversing feel laggy by one frame. Rounding keeps forward and reverse playback symmetric.
The frame counter is part of the same update
The caption's "Frame 38 / 80" label updates inside the same onUpdate as the blit, so the number and the pixels can never drift apart. font-variant-numeric: tabular-nums keeps the counter from jittering horizontally as digits change width.
Swapping in real exported frames
To use a real sequence, replace the procedural loop with an Image() preloader: build URLs like frame_0001.jpg … frame_0080.jpg, push each loaded image into frames, and start the ScrollTrigger once Promise.all resolves. Everything downstream — the proxy, rounding, and blitting — stays identical, because drawImage() accepts images and canvases interchangeably.
Canvas resolution is decoupled from display size
The canvas's attribute size (640×400) fixes the bitmap resolution, while CSS scales it down responsively with width: min(640px, 92vw) — downscaling a fixed bitmap always stays sharp. For retina-crisp output, multiply the attribute size by devicePixelRatio, render frames at that resolution, and keep the CSS size unchanged; every drawImage call then blits at native density. Because the frames were pre-rendered at the same resolution as the visible canvas, no per-frame scaling interpolation happens during the scrub — the blit is a straight memory copy.
Customizing it
Raise FRAMES for smoother motion (120–150 for hero sequences), stretch end for a slower scrub, or layer HTML captions that fade in at timeline positions alongside the canvas. Pair it with a scroll zoom hero for the section before, a scroll text clip reveal for copy, or a scroll parallax layers section after the sequence ends.
Build with AI
Build, Understand, Optimize, and Extend It With AI
You don't have to trace the frame math or the proxy-object tween pattern alone. Paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain exactly why GSAP tweens a plain playhead object instead of the canvas directly, or why Math.round rather than Math.floor keeps forward and reverse playback symmetric. The same assistant can help optimize it — checking whether pre-rendering 80 offscreen canvases up front is worth the memory versus lazily decoding real image frames, or whether the resolution should scale with devicePixelRatio for retina screens. It's also useful for extending the effect: ask it to swap the procedural particle ring for a real exported PNG sequence loaded with an Image preloader, add captions that change at specific frame numbers, or sync the frame counter to a second scrubbed element. Treat the code less like a finished artifact and more like a starting point for a conversation.
Prompt to recreate it
Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:
Build a "scroll image sequence" effect in plain HTML, CSS, and JavaScript using GSAP with its ScrollTrigger plugin (load both from a CDN) and the Canvas 2D API — no video element, no WebGL.
Requirements:
- Pre-render a fixed number of frames (e.g. 80) once at load time, each painted into its own offscreen canvas created with document.createElement, not redrawn on every scroll event.
- Store all the offscreen canvases in an array indexed by frame number.
- Create a single plain JavaScript object with a numeric "frame" property and tween that object's frame value from 0 to frames.length - 1 using GSAP, with ease set to none.
- Drive that tween from a ScrollTrigger with pin: true and scrub set to a smoothing value (not a boolean) so the motion glides slightly between wheel ticks, over a scroll distance long enough that each frame corresponds to roughly 15-30px of scroll.
- In the tween's onUpdate callback, round the current playhead frame value to the nearest integer (not floor, so forward and reverse feel symmetric) and draw that frame's offscreen canvas onto the visible canvas with a single drawImage call after clearing it.
- Update a text counter showing the current frame number out of the total inside the same onUpdate callback so the label can never drift out of sync with the drawn frame.
- Confirm scrolling back up plays the frames in reverse order automatically because the tween is scrubbed, with no separate reverse-playback code path.Step by step
How to Use
- 1Add the GSAP CDNsInclude gsap and ScrollTrigger from the CDN panel.
- 2Paste HTML, CSS, and JS80 frames pre-render into offscreen canvases on load.
- 3Scroll into the stageThe section pins and the sequence starts playing.
- 4Watch the counterThe frame label tracks the exact frame on screen.
- 5Scroll back upThe animation runs in reverse, frame-perfect.
- 6Swap in real framesReplace the procedural loop with an Image() preloader.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
GSAP tweens a plain proxy object from frame 0 to frame 79 on a pinned, scrubbed ScrollTrigger. The tween's onUpdate rounds the float to an integer and blits that pre-rendered frame with drawImage. Because scrub ties tween progress to scroll position, the scrollbar literally is the playhead — forward, backward, or stopped mid-frame.
Painting geometry per scroll event would redo gradient, arc, and trig work dozens of times a second. Pre-rendering into offscreen canvases moves all of that to load time, so the scroll path is a single drawImage blit of a finished bitmap — the same reason production sites export PNG frames rather than re-rendering 3D per tick.
Replace the procedural loop with an Image() preloader: generate numbered URLs (frame_0001.jpg and so on), assign each to a new Image, push them into the frames array, and create the ScrollTrigger inside Promise.all so scrubbing never hits an undecoded frame. drawImage accepts images and canvases interchangeably, so nothing else changes.
The demo maps 80 frames across end: '+=250%', about 20px of scroll per frame, which reads as continuous motion. For hero sequences 100–150 frames is typical; keep roughly 15–30px of scroll per frame by scaling end with the frame count, and let scrub smoothing (0.3–0.6) hide any remaining stepping.
You can tween video.currentTime with the same proxy pattern, but browsers only seek quickly to keyframes — scrubbing between them causes visible stalls, and Safari throttles rapid seeks aggressively. That's exactly why Apple-style pages export frames instead: a canvas blit is deterministic at any playhead position. If you must use video, re-encode it with a keyframe every frame (all-intra) to make seeking scrub-safe.
Run the frame pre-render and ScrollTrigger setup in a mount effect (useEffect, onMounted, or ngAfterViewInit) with a ref to the canvas instead of getElementById. Store the frames array in a ref so re-renders don't rebuild it, and return a cleanup that kills the trigger (or reverts a gsap.context) so the pin is removed on unmount. Tailwind can replace the layout CSS; the canvas logic ports unchanged.