tool / image sequencescroll · frame · canvas
Scroll image sequence generator
The product-page effect drawn one still at a time: a numbered image sequence painted on a canvas as the page scrolls. Try it on the demo, on frames of your own, or on frames this page draws from a video in your browser and hands you as a zip; pin it or let it scroll, and copy the HTML, CSS and JavaScript or a React component.
- Options
- 13
- Demo frames
- 72
- Uploads
- 0
- Library
- 2.27.3
Scrub an image sequence with the scroll
The box below is a small window. Scroll it, drag the slider or press Play: the scroll is turned into a progress with scrollFrames’ own arithmetic and handed to a real scrollFrames instance, which draws the frame on the canvas. The demo’s 72 frames are drawn in your browser, so it starts at once; pick your own frames, or turn a video into frames, and the preview, the readouts and the code follow.
72 frames of a box turning half a turn, 960×600 JPEGs drawn in this browser with canvas 2D, so the preview needs nothing from a server. The code is written for the same frames hosted as frames/frame_0001.jpg … frame_0072.jpg.
'top top' → 'bottom bottom'. Exactly while the stage is stuck: from the track’s top at the window’s top to its bottom at the window’s bottom.Window · 360px tall · scroll it · the stage scrolls
Drawing the demo frames…
Waiting for the frames.
- Frames
- 72
- Each
- 960×600
- All, on disk
- –
A visitor downloads every one of them (the last only, under reduced motion).
// Made with https://svg-scroll-draw.vercel.app/scroll-image-sequence-generator
import { scrollFrames } from 'svg-scroll-draw/frames';
// 72 frames: frames/frame_0001.jpg … frames/frame_0072.jpg
const film = scrollFrames(document.querySelector('#frames'), {
frames: (i) => `frames/frame_${String(i + 1).padStart(4, '0')}.jpg`,
count: 72,
// The stage is sticky, so the canvas's own box moves with the scroll.
// Measure the track that holds the scroll room instead.
triggerElement: document.querySelector('#frames-track'),
trigger: { start: 'top top', end: 'bottom bottom' },
});
// film.destroy() stops it and the loads still in flight. With reduced
// motion on, scrollFrames loads and shows the last frame only.The HTML, CSS and JavaScript tabs are one page; options at scrollFrames’ defaults are left out (concurrency stays at 6). The downloaded file draws the demo frames itself, so it runs on its own.
scrollContainer option, so the preview works out the progress from the box’s scroll (the same arithmetic, fig. 1) and passes it to the instance’s seek(). On your page the instance follows the window by itself.URL.createObjectURL: they never leave this tab, and the URLs are let go when you load another set or leave the page.From a scroll position to a frame
scrollFrames measures one element, the canvas or its triggerElement, and turns the two trigger pairs into two scroll positions, as every scroll animation in the library does. Between them, progress runs from 0 to 1 in a straight line; the easing bends it; and the frame is that share of the way through the sequence, rounded: round(progress × (count − 1)) (fig. 1).
// What scrollFrames does on each animation frame the scroll moves
// (frames/index.ts in svg-scroll-draw), simplified:
const progress = Math.min(1, Math.max(0, (scrollY - startAt) / (endAt - startAt)));
const wanted = Math.round(easing(progress) * (count - 1));
// The frame drawn is the loaded one nearest `wanted`, and the canvas is
// redrawn only when that changes, not on every animation frame.
const show = nearestLoaded(wanted);
if (show !== drawn) {
draw(images[show]); // cover or contain, centred
drawn = show;
}concurrency (6) at a time. A reader who scrolls fast has a frame near wherever they land after a handful of loads; until the one the scroll calls for arrives, the nearest loaded one shows, and the canvas redraws as closer ones come in. A frame that fails to load is skipped the same way, with one console warning naming its URL.devicePixelRatio, capped at 2, and redraws when the box changes size (a ResizeObserver) or the window does.start: 'top top' and end: 'bottom bottom': the stretch the stage is stuck.Every scrollFrames option
scrollFrames(target, options) takes a <canvas> or a selector for one and returns an instance. Leave an option out and it takes the default; the generator leaves out every option at its default.
| Option | Type | Default | What it does |
|---|---|---|---|
| frames | string[] | (i) => string | required | The image URLs in order, or a function from a 0-based index to a URL. A function needs count. |
| count | number | frames.length | How many frames. Required when frames is a function; with a list, it can only shorten it. |
| trigger | { start?, end? } | 'top top' → 'bottom top' | The scroll window, in the two-word form: an edge of the measured element, then a line across the window. Progress is 0 at start and 1 at end. The default turns the frames from when the canvas’s top reaches the top of the window until its bottom does: while it leaves. |
| triggerElement | Element | the canvas | Measure the window on this element instead of the canvas. A canvas in a sticky stage moves with the scroll while it is stuck, so measure the tall track that holds the scroll room. |
| easing | 'linear' | 'ease-in' | … | (t) => number | 'linear' | Shapes progress before it picks a frame. |
| once | boolean | false | Hold the furthest frame reached: scrolling back up does not turn it back. |
| axis | 'x' | 'y' | 'y' | Which scroll to follow. 'x' reads the window’s horizontal scroll and the element’s left edge and width. |
| fit | 'cover' | 'contain' | 'cover' | How each frame fills the canvas: cover fills it and crops, contain shows the whole frame; both centred. |
| concurrency | number | 6 | How many images load at once. |
| onReady | () => void | — | The first frame is drawn. |
| onLoad | () => void | — | Every frame has loaded or failed. |
| onProgress | (alpha, frame) => void | — | Each time the scroll moves it: the eased progress, and the index of the frame it calls for. |
| onComplete | () => void | — | When progress reaches 1, and again after it has dropped below 1 (unless once). |
seek(p), pause(), resume(), replay(), getProgress() (the eased progress) and destroy(), which takes the observers and listeners off and stops the loads still in flight. seek(p) shows progress p and stops the instance following the scroll until resume(). On the server, for a target that is not a canvas, or with no frames, scrollFrames returns an instance that does nothing.role="img" and an aria-label that says what the sequence shows: the generated code has both, with a label to replace.Video to image sequence
Most sequences start as a video: a render, a turntable shot, a screen recording. The generator’s From a video tab turns one into frames without leaving the page: it seeks a <video> to the middle of each of N equal slices of the file in turn, waits for seeked, draws the picture onto a canvas at the width you chose, and saves it with toBlob as WebP or JPEG. The frames go straight into the preview, and Download frames.zip hands them over as frames/frame_0001.webp and on, the names the code asks for. Try it on this site’s film with one click.
Or with ffmpeg, from a terminal, for a long video or a pipeline:
mkdir frames
# WebP, 12 frames for each second of video, 960px wide (the height follows,
# rounded to an even number); numbered from 1: frames/frame_0001.webp …
ffmpeg -i input.mp4 -an -vf "fps=12,scale=960:-2" \
-c:v libwebp -quality 80 frames/frame_%04d.webp
# The same as JPEG, for a browser or a pipeline without WebP
ffmpeg -i input.mp4 -an -vf "fps=12,scale=960:-2" -q:v 3 frames/frame_%04d.jpg
# How many came out: the count to pass
ls frames | wc -lfps=12 gave 65 frames, numbered from frame_0001, 960×720. The WebP line needs an ffmpeg built with libwebp (most are); the -quality and -q:v values are a starting point, not a measurement.Frame size, weight and hosting
A sequence is paid for frame by frame: a reader downloads every image, and each one, decoded, takes about width × height × 4 bytes of memory. The generator’s readout adds up the set you have loaded: its files on disk, the average frame, and the decoded total.
devicePixelRatio, capped at 2. A frame wider than twice the widest the canvas is ever shown costs bytes and memory for nothing; for a full-width stage, 1280 to 1920 px is plenty, and a phone needs far less.public/frames for Next.js or Vite, and point the URL pattern at it. Unlike a video, an image needs no Range requests. Give the folder a long cache lifetime: the frames do not change once made, so rename them (or the folder) when they do.Use it in your project
scrollFrames has an entry of its own, svg-scroll-draw/frames, and the generated code imports it from there. It is not in the script-tag build, so the HTML file imports the ES module from jsDelivr.
Image sequence questions
How do I make an Apple-style scroll animation with an image sequence?
Export the animation as numbered stills, draw them on a <canvas> one at a time, and pick the still from how far the reader has scrolled. scrollFrames from svg-scroll-draw/frames does the drawing: it works out a progress from 0 to 1 over a trigger window and draws frame round(progress × (count − 1)), the nearest loaded one until that arrives. For the product-page version, put the canvas in a position: sticky stage inside a tall track and point triggerElement at the track, with start 'top top' and end 'bottom bottom'. The generator above writes all of it, and turns a video into the stills.
Image sequence or video: which should I scrub on scroll?
A video is one file, much smaller for the same footage, but every scroll change is a seek, and a seek decodes from the keyframe before the frame asked for: encoded for playback, scrubbing stutters (the scroll video scrub generator measures it). An image sequence shows any frame at once in every browser, because each one is a picture already decoded, at the cost of many requests and more bytes and memory. Short, high-value sequences, such as a product turning, suit images; long footage suits a video encoded with a keyframe every few frames.
How do I turn a video into an image sequence?
In the generator, choose From a video, pick a file, set the number of frames, the width, the format and the quality, and press Extract: the video is sought to each time in turn in your browser and drawn onto a canvas, and you can download the frames as frames.zip, named frame_0001.webp and on. Nothing is uploaded. With ffmpeg, the lines in section 04 give the same naming.
How many frames should a scroll image sequence have?
Enough that one frame does not cover a visible jump of scroll, and few enough that the reader can download them all. The generator shows how many scroll pixels each frame covers and the total weight of the set as you change them. Each frame is a separate request and, once decoded, about width × height × 4 bytes of memory; the weight readout adds that up for your set.
WebP, AVIF or JPEG for the frames?
Any format the browser can show as an <img> works: scrollFrames loads each frame with new Image(). WebP is usually smaller than JPEG at the same quality and every current major browser shows it. The in-browser extractor writes WebP or JPEG, because those are the formats a canvas can save; Safari’s canvas cannot write WebP, so it offers JPEG there. For AVIF, convert the frames with a tool that encodes it.
What happens with reduced motion on?
Under prefers-reduced-motion: reduce, scrollFrames loads only the last frame, draws it, and does not follow the scroll. Turned on during a visit, the scrub stops on the last frame and nothing more is loaded. There is no option to change this; make the last frame one that stands on its own.
Is there a React component?
Yes: <ScrollFrames> from svg-scroll-draw/react (Vue has one too, in svg-scroll-draw/vue). It renders the canvas with role="img", takes every option as a prop, with a ref to the track as triggerElement when pinned, and stops the film and the loads still in flight when it unmounts. A frames function written inline is compared by the URLs it gives, so a re-render keeps the film. The React tab writes exactly that.
More with svg-scroll-draw 2.27.3: the pinned image sequence recipe, on 71 frames of NASA’s Landsat 8, scrollFrames in the docs, the scroll video scrub generator for the same effect from one video file, and the scroll pin generator.