Guide
Extract frames from an AI video for a scroll animation canvas
A scroll-driven canvas animation is a numbered image sequence plus about 60 lines of JavaScript. This guide takes a clip out of Sora, Runway, Kling or Veo (or any MP4, WebM, GIF or animated WebP), turns it into frame_0001.jpg through frame_0240.jpg, and wires those frames to scroll position the way Apple product pages do.
What a scroll-driven canvas animation is, and why it needs frames instead of a video tag
On an Apple product page the hero does not play. It scrubs. Scroll down and a watch face rotates, an iPhone unfolds, a pair of headphones turns in place, and the motion tracks your finger exactly. Scroll back up and it runs backwards at the same speed. There is no video element on the page. There is a <canvas>, a few hundred pre-rendered JPGs, and a scroll handler that picks which one to draw.
The obvious alternative, setting video.currentTime from the scroll position, falls apart in practice. A seek on a compressed video has to find the nearest keyframe and decode forward from there, so the browser answers most seeks late and out of order. You get stalls on iOS, a decode queue that lags a full second behind the scrollbar, and frame drops the moment two seeks land in the same tick. Video codecs are built to play forward at a fixed rate, not to be addressed randomly 60 times a second.
A frame sequence has none of that. Frame 137 is a decoded bitmap sitting in memory, and drawing it is one drawImage call that costs well under a millisecond. The mapping from scroll position to frame index is deterministic: the same scroll offset always draws the same picture, on every browser, in both directions. You pay for it in bytes, which is why most of this guide is about keeping the sequence small.
Step 1: get a clean clip out of Sora, Runway, Kling or Veo
The sequence is only as good as the source, and the constraints are tighter than for a clip you would just play.
- Length: 3 to 8 seconds. Scroll animations are short by nature, because the user has to scroll through the whole thing. Eight seconds of source spread over 500vh of page is already a long pin. Most AI generators default to 5 or 10 seconds, so a 5-second clip needs no trimming at all.
- Constant frame rate. Some AI exports carry a variable frame rate, which makes frame N land at an unpredictable timestamp. Asking the extractor for a fixed 24 or 30 fps resamples the clip onto an even grid, which is what the linear scroll-to-index mapping assumes.
- Resolution: render wide, export at 1920. Generate at the highest resolution your model offers, then let the extractor downscale. 1920 px wide is the default and matches what Apple ships (their sequences sit around 1500 to 2000 px). Going past that inflates the download without a visible gain on any display that will show it in a hero.
- 24 or 30 fps is enough. AI models generate at 24 or 30 fps, so there is nothing above that to capture. Requesting 60 fps from a 30 fps source gives you 60 files per second of which every other one is a duplicate: twice the weight, zero extra motion.
- Lock the camera or the subject. A sequence reads as one continuous object when either the camera or the subject holds still. Clips where both move tend to look like a video someone scrubbed, which is the effect you are trying to avoid.
Step 2: extract the frames into a numbered sequence
Drop the clip into the extractor on this site. It decodes locally with WebCodecs, so the file never leaves your machine, and hands back a ZIP of numbered images.
Open the Video Frame Extractor →MP4, WebM, MOV, GIF or animated WebP in, a ZIP of frame_0001.jpg out. Runs in the browser, nothing is uploaded.The five settings that matter, and what to set them to:
- FPS (slider, 1 to 60, default 24). Set 24 for a cinematic reveal, 30 when the motion is fast enough that 24 feels steppy. This is the single biggest lever on total weight.
- Output width (Original, 1920, 1440, 1080, 720, or a custom value). 1920 for a desktop hero, 1080 or 720 for a mobile-only sequence. Aspect ratio is preserved.
- Format (JPG or WebP). WebP for the web, JPG when something downstream needs it.
- Quality (slider, 0.10 to 1.00, default 0.85). 0.85 is the photographic sweet spot. Because each frame is on screen for a fraction of a second, you can usually drop to 0.75 before anyone notices.
- ZIP output and naming. One download,
frames.zip, containingframe_0001.jpg,frame_0002.jpgand so on, zero-padded to four digits. That padding is what lets the loop below build a URL from an index withString(i + 1).padStart(4, "0").
Before you press Extract the tool shows the resulting frame count and an estimated ZIP size. For an 8-second clip that is 192 frames at 24 fps or 240 at 30 fps, which is the same ballpark as Apple's AirPods Max sequence. The tool warns above 1000 frames and refuses past 2500, because JSZip holds everything in memory until you download.
Unzip into public/frames/ (or whatever your static directory is) and you are done with the video half.
Step 3: the HTML, CSS and JavaScript for a scroll-driven canvas
Three pieces: a tall stage that provides the scroll distance, a sticky track that pins the canvas inside it, and a handler that turns the stage's position in the viewport into a frame index.
index.html
<section class="scroll-stage">
<div class="scroll-track">
<canvas id="hero" class="scroll-canvas"></canvas>
</div>
</section>style.css
/* 500vh of scroll drives 100vh of sticky canvas. */
.scroll-stage {
position: relative;
height: 500vh;
}
.scroll-track {
position: sticky;
top: 0;
height: 100vh;
overflow: hidden;
}
.scroll-canvas {
display: block;
width: 100%;
height: 100%;
}
/* No scroll hijacking for anyone who asked for less motion:
the stage collapses to one screen and shows a single frame. */
@media (prefers-reduced-motion: reduce) {
.scroll-stage { height: 100vh; }
}scroll-frames.js
const FRAME_COUNT = 240; // 8 s of source at 30 fps
const PRELOAD = 20; // frames that must decode before first paint
const stage = document.querySelector(".scroll-stage");
const canvas = document.getElementById("hero");
const ctx = canvas.getContext("2d", { alpha: false });
const images = new Array(FRAME_COUNT);
function frameUrl(i) {
// Matches the extractor's output: frame_0001.jpg ... frame_0240.jpg
return `/frames/frame_${String(i + 1).padStart(4, "0")}.jpg`;
}
function load(i) {
if (!images[i]) {
const img = new Image();
img.src = frameUrl(i);
images[i] = img;
}
return images[i];
}
// Match the backing store to the CSS box at the device pixel ratio.
// Capped at 2: a 3x phone would quadruple the fill cost for nothing.
function resize() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const rect = canvas.getBoundingClientRect();
canvas.width = Math.round(rect.width * dpr);
canvas.height = Math.round(rect.height * dpr);
draw(current);
}
// cover-fit: fill the canvas, crop the overflow, never stretch.
function draw(i) {
const img = images[i];
if (!img || !img.complete || img.naturalWidth === 0) return;
const scale = Math.max(
canvas.width / img.naturalWidth,
canvas.height / img.naturalHeight,
);
const w = img.naturalWidth * scale;
const h = img.naturalHeight * scale;
ctx.drawImage(img, (canvas.width - w) / 2, (canvas.height - h) / 2, w, h);
}
let current = 0;
let ticking = false;
function frameIndexFromScroll() {
const rect = stage.getBoundingClientRect();
const scrollable = rect.height - window.innerHeight;
const progress = scrollable > 0 ? -rect.top / scrollable : 0;
const clamped = Math.min(Math.max(progress, 0), 1);
return Math.round(clamped * (FRAME_COUNT - 1));
}
// One draw per animation frame, never one per scroll event.
function onScroll() {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
const next = frameIndexFromScroll();
if (next !== current) {
current = next;
draw(current);
}
ticking = false;
});
}
async function start() {
await Promise.all(
Array.from({ length: PRELOAD }, (_, i) =>
load(i).decode().catch(() => {}),
),
);
current = frameIndexFromScroll(); // survives a reload mid-page
resize();
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
draw(0);
return; // static poster frame, no scroll handler
}
for (let i = PRELOAD; i < FRAME_COUNT; i++) load(i);
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", resize);
}
start();Four details in there are the ones people usually get wrong. The throttle: scroll fires far more often than the screen refreshes, so the handler sets a flag and does the real work inside requestAnimationFrame, at most once per painted frame. The cover fit: drawImage with four destination arguments will happily stretch your frame, so the scale is the larger of the two ratios and the result is centred. The initial index: reading the scroll position before the first draw stops the sequence from snapping back to frame 1 when someone reloads halfway down. The reduced-motion branch: under prefers-reduced-motion: reduce the stage collapses to one screen and the page draws a single frame with no scroll handler attached at all.
What about CSS scroll-timeline?
animation-timeline: scroll() and view() link a CSS animation to scroll position with no JavaScript, and they are the right tool for pinned text, parallax layers and reveal-on-enter effects:
@keyframes rise {
from { opacity: 0; transform: translateY(40px); }
to { opacity: 1; transform: none; }
}
.caption {
animation: rise linear both;
animation-timeline: view();
animation-range: entry 0% cover 40%;
}It does not replace the canvas, though. Swapping which bitmap is drawn is not an animatable CSS property, so a frame sequence still needs the scroll handler above. Mix them: CSS timelines for the captions that ride along, JavaScript for the frames.
Step 4: performance, or how to keep a frame sequence under a few megabytes
- WebP over JPG. 25 to 35 percent smaller at the same perceived quality. On 240 frames at 1920 px that is roughly 8 MB instead of 12 MB, for one line changed in
frameUrl(). - Count beats quality. Halving the frame count halves the bytes and costs you smoothness only below 24 fps. Dropping quality from 0.85 to 0.60 saves less and shows up as mush on a still. Trim the clip and lower the fps first.
- Decode before you draw.
img.decode()resolves once the bitmap is actually ready. Awaiting it on the first batch means the firstdrawImagehits a decoded image instead of silently drawing nothing, which is the usual cause of a blank hero on load. - Preload 20, then stream the rest. Twenty frames is about a second of scroll, enough to start with. Kicking off the remaining requests right after keeps the network busy without blocking the first paint. Do not wait for all 240.
- Serve the frames from a CDN with a long cache. The sequence is 240 immutable files requested in a burst. Hash the directory name, set
Cache-Control: public, max-age=31536000, immutable, and put it behind a CDN so the requests are parallel and close to the user. HTTP/2 or HTTP/3 matters here: 240 requests on HTTP/1.1 queue six at a time. - Ship a smaller mobile sequence. Export a second pass at 720 or 1080 px and pick the directory from
window.innerWidthbefore the preload starts.
Common problems with scroll animation frame sequences
- Frames look blurry or soft. The canvas backing store is at CSS pixels while the screen is at 2x or 3x. Set
canvas.widthandcanvas.heightto the bounding rect multiplied bydevicePixelRatio(capped at 2) and let CSS keep the element at 100 percent, exactly asresize()does above. Resizing the canvas clears it, so redraw immediately after. - The animation flickers or blanks while scrolling. You are drawing frames that have not decoded. An incomplete
Imagedraws nothing at all. Guard onimg.complete, awaitdecode()on the preload batch, and start the background loading early enough that a fast flick does not outrun it. - It jumps on load. The initial index was hardcoded to 0 while the browser restored a scroll position halfway down the stage. Compute the index from the current scroll before the first draw.
- The page weighs 40 MB. Too many frames, too wide, or quality too high. A 5-second clip at 24 fps, 1920 px, quality 0.85 in WebP lands around 4 to 6 MB. If you are far above that, re-extract with a lower fps before touching anything else.
- The last frame never shows. The stage is shorter than
height + 100vh, so scroll progress never reaches 1. Give the stage enough height thatrect.height - window.innerHeightis a comfortable distance: 500vh for 240 frames is a good starting point.
Frequently asked questions
How many frames do I need for a scroll animation?
- About 240 for an 8 second scroll at 30 fps, or 192 at 24 fps. Apple's AirPods Max page runs roughly 240 frames; the iPhone-fold sequence is 24 fps. Below 24 fps the motion stutters when someone scrolls slowly, and above 30 fps you double the download for motion nobody notices. Pick the clip length first, multiply by the fps, and that is your frame count.
Can I use a Sora or Runway video?
- Yes. Sora, Runway, Kling, Veo and Pika all export MP4 or WebM, which is exactly what the extractor reads. AI clips are short (usually 4 to 10 seconds) and sometimes carry an odd or variable frame rate, so set the target fps to 24 or 30 and the extractor resamples the clip to it.
JPG or WebP for frame sequences?
- WebP, unless you have a reason not to. It is 25 to 35 percent smaller than JPG at the same perceived quality and every browser released since 2020 decodes it. On a 240-frame sequence that is the difference between roughly 12 MB and 8 MB. Use JPG when you have to support very old Safari or when the frames also feed a legacy pipeline.
Does a scroll-driven canvas animation work on mobile?
- Yes, but halve the budget. Serve a second sequence at 720 or 1080 px wide and cap the device pixel ratio at 2, or a phone downloads desktop-sized frames over a mobile connection. iOS Safari fires scroll events during momentum scrolling, so the requestAnimationFrame throttle in the example below is what keeps the frame rate steady rather than an optimisation you can skip.
Do I need GSAP or ScrollTrigger?
- No. The whole thing is about 60 lines of plain JavaScript: a sticky canvas, a preloaded array of images, and a scroll handler that maps scroll progress to a frame index. GSAP ScrollTrigger is worth it when you are sequencing several pinned sections against each other, not for a single frame sequence.
Is the video uploaded anywhere?
- No. The extractor decodes, resizes and zips the frames inside your browser tab using WebCodecs and JSZip. Open DevTools and watch the Network tab while you click Extract: no request goes out. The clip stays on your machine.
Why does my canvas animation flicker while scrolling?
- Because the frame you are drawing has not decoded yet. An Image element that is still loading draws nothing, so the canvas keeps whatever was there or goes blank. Await decode() on the first batch before the first draw, and keep the rest of the sequence loading in the background so a fast scroll never outruns the network.
Can I do this with CSS scroll-timeline instead?
- Only for the parts you can express as a CSS animation. animation-timeline: scroll() and view() drive transforms, opacity and colour against scroll position with no JavaScript, which covers pinned text and parallax. Swapping images in a canvas is not a CSS animatable property, so a frame sequence still needs the scroll handler below.