Image Sequence
Play frame-by-frame image sequences synced to scroll or triggers.
Image Sequence renders a series of numbered image files frame-by-frame onto a <canvas> element, synced to any Motion.page trigger. It’s commonly used for scroll-driven product spins, 3D object rotations, and cinematic parallax reveals.
How It Works
Image Sequence targets a <canvas> element via a CSS selector and loads pre-numbered image files — e.g. 0001.jpg, 0002.jpg — into decoded ImageBitmap objects. As the timeline progresses (from any trigger), it maps the 0–1 progress value to a frame index and draws it via CanvasRenderingContext2D.
Loading is prioritised in three stages:
- First frame — loaded and drawn immediately on mount so the canvas is never blank.
- Priority queue — the first 25% of frames load sequentially for a fast initial scrub.
- Remaining frames — loaded after the priority queue completes, or deferred until the canvas enters the viewport when
isIntersectionObserveris enabled.
HTML Setup
Add a <canvas> element to your page with a CSS selector you can target. Set explicit dimensions so the canvas scales correctly:
<canvas class="sequence" width="1920" height="1080"></canvas> The canvas is sized in CSS — the width/height attributes define the drawing buffer resolution. Match the aspect ratio of your image frames.
Basic Usage
Add an imageSequence object to your animation config and chain any trigger.
import { Motion } from "@motion.page/sdk";
Motion("product-spin", "canvas.sequence", {
imageSequence: {
canvas: "canvas.sequence",
urls: "/images/sequence/",
ext: "jpg",
totalFrames: 120,
numPadding: 4,
},
}).onScroll({
trigger: "canvas.sequence",
start: "top top",
end: "bottom bottom",
scrub: true,
});
imageSequenceis a root-level property on the animation config — not placed insidefromorto. Standardfrom/toproperties are not needed; the timeline length is driven by the trigger’s scroll range.
Image URL Pattern
Image filenames are constructed from three properties:
${urls}${paddedIndex}.${ext}
// e.g. /images/sequence/0001.jpg urls— the base directory path, including a trailing slashnumPadding— zero-padding width for the index (default4→0001,0002, …)ext— file extension
Supported formats:
| Format | Extension | Alpha channel |
|---|---|---|
| JPEG | jpg | ❌ — opaque canvas, best performance |
| PNG | png | ✅ — alpha canvas enabled automatically |
| WebP | webp | ✅ — alpha canvas enabled automatically |
| AVIF | avif | ✅ — alpha canvas enabled automatically |
Transparent formats (png, webp, avif) automatically switch the canvas to an alpha context and clear the rect before each frame draw.
Options
| Option | Type | Default | Description |
|---|---|---|---|
canvas | string | — | CSS selector for the target <canvas> element |
urls | string | — | Base path for image files (directory URL, trailing slash required) |
ext | string | "jpg" | Image file extension (jpg | png | webp | avif) |
totalFrames | number | — | Total number of frames in the sequence |
numPadding | number | 4 | Zero-padding width for filenames (4 → 0001.jpg) |
imageFit | number | 0 | How the image fits the canvas: 0 = cover, 1 = contain, 2 = fill |
skipFrames | number | 0 | Skip every Nth frame to reduce image count (0 = none, 2–20) |
frames | [number, number] | [0, 1] | Restrict playback to a fraction of the sequence (0–1 normalized range) |
isReverse | boolean | false | Play the sequence in reverse direction |
isIntersectionObserver | boolean | false | Defer non-priority image loading until canvas enters the viewport |
intersectionObserverMargin | number | 0 | Viewport offset (%) for intersection observer trigger |
dpr | number | 1.25 | Device pixel ratio multiplier for canvas resolution |
isDebug | boolean | false | Log debug information to the console |
whenDisabled | string[] | [] | Conditions under which to disable the sequence and apply fallback |
fallbackMode | number | 0 | Fallback strategy when a disable condition is met |
smallImagesRoot | string | "" | Alternative image base path for viewports narrower than 768px |
mediumImagesRoot | string | "" | Alternative image base path for viewports narrower than 1024px |
Image Fit Modes
Controls how each frame is drawn relative to the canvas bounds:
- Cover (
0) — fills the entire canvas, cropping edges if the aspect ratio differs. No stretching. Default. - Contain (
1) — fits the full image within the canvas, adding transparent bars if needed. Requirespng,webp, oravif. - Fill (
2) — stretches the image to exactly match canvas dimensions.
// Contain mode — letterbox with transparency
Motion("product", "canvas.sequence", {
imageSequence: {
canvas: "canvas.sequence",
urls: "/images/sequence/",
ext: "png", // ✅ alpha format required for contain
totalFrames: 60,
imageFit: 1,
},
}).onScroll({ scrub: true });
imageFit: 1(contain) requires an alpha-capable format — usepng,webp, oravif. Usingjpgwith contain will show a black background behind the letterboxed image.
Scroll-Synced Playback
The most common use case. Setting scrub: true maps scroll progress directly to frame index — scrolling forward advances frames, scrolling back reverses them.
import { Motion } from "@motion.page/sdk";
Motion("car-spin", "canvas.car", {
imageSequence: {
canvas: "canvas.car",
urls: "/images/car-spin/",
ext: "jpg",
totalFrames: 180,
numPadding: 4,
},
}).onScroll({
trigger: ".car-section",
start: "top top",
end: "+=300%",
scrub: 1,
pin: true,
}); Pin the trigger element and extend the scroll distance with end: "+=300%" to give users enough room to scrub through all frames comfortably. See Scroll Trigger and Scroll Trigger Advanced for full pin and scrub options.
Frame Range
Use frames to restrict playback to a sub-range of the total sequence. Values are normalized 0–1 fractions of totalFrames.
// Only play frames 25%–75% of the sequence
Motion("partial-spin", "canvas.sequence", {
imageSequence: {
canvas: "canvas.sequence",
urls: "/images/sequence/",
ext: "jpg",
totalFrames: 120,
frames: [0.25, 0.75], // frames 30–90
},
}).onScroll({ scrub: true }); This is useful when multiple scroll sections each drive a different chapter of the same image sequence.
Responsive Images
Serve smaller, lower-resolution frame sets on mobile to reduce bandwidth and improve load time. Provide alternative base paths for smaller breakpoints:
Motion("product-spin", "canvas.sequence", {
imageSequence: {
canvas: "canvas.sequence",
urls: "/images/sequence/large/", // > 1024px
mediumImagesRoot: "/images/sequence/medium/", // 768–1024px
smallImagesRoot: "/images/sequence/small/", // < 768px
ext: "jpg",
totalFrames: 120,
numPadding: 4,
},
}).onScroll({ scrub: true }); Breakpoints:
| Property | Viewport width | Fallback |
|---|---|---|
smallImagesRoot | < 768px | urls |
mediumImagesRoot | < 1024px | urls |
urls | ≥ 1024px | — |
If smallImagesRoot or mediumImagesRoot is empty, urls is used at all breakpoints.
Performance
Image sequences are asset-heavy. Apply these optimisations to keep load times and memory usage under control:
- Use
jpgfor opaque sequences — no alpha overhead, smallest file size. - Enable
isIntersectionObserverfor sequences below the fold — remaining frames won’t load until the canvas enters the viewport. - Use
skipFramesto reduce total loaded images.skipFrames: 2loads every other frame, halving the request count while keeping motion smooth at typical scroll speeds. - Serve responsive sets via
smallImagesRoot/mediumImagesRoot— mobile devices load compressed 768px frames instead of full-resolution ones. - Lower
dpron mobile to reduce canvas buffer size.dpr: 1uses native device pixels;dpr: 1.25sharpens slightly at a small memory cost. - Keep frame count reasonable — 60–180 frames covers most use cases. Higher counts rarely improve perceived smoothness.
// Optimised for mobile-first performance
Motion("optimised-spin", "canvas.sequence", {
imageSequence: {
canvas: "canvas.sequence",
urls: "/images/spin/large/",
smallImagesRoot: "/images/spin/small/",
ext: "jpg",
totalFrames: 90,
skipFrames: 2, // load every other frame
isIntersectionObserver: true,
dpr: 1,
},
}).onScroll({ scrub: true }); Fallback & Disable Conditions
Disable the sequence under specific conditions and define what to show instead. This prevents a blank canvas on slow connections or unsupported environments.
whenDisabled — an array of condition strings. The sequence is disabled if any condition matches:
| Value | Condition |
|---|---|
"SLOW_CONNECTION" | Detected download speed < 1.2 Mbps or first frame took > 600ms |
"MOBILE" | Mobile user-agent detected |
"TOUCH" | Device has touch points |
"PC" | Desktop device |
"CHROME" | Chrome browser |
"SAFARI" | Safari browser |
"FIREFOX" | Firefox browser |
"EDGE" | Edge browser |
"IE" | Internet Explorer |
"MOBILE_SAFARI" | Safari on iOS |
fallbackMode — what to display when disabled:
| Value | Behaviour |
|---|---|
0 | Display the first frame as a static <img> (default) |
4 | Display the last frame as a static <img> |
2 | Remove the <canvas> element from the DOM |
3 | Leave the canvas empty |
5 | No fallback — do nothing |
// Disable on mobile and slow connections, show first frame as fallback
Motion("product-spin", "canvas.sequence", {
imageSequence: {
canvas: "canvas.sequence",
urls: "/images/sequence/",
ext: "jpg",
totalFrames: 120,
whenDisabled: ["MOBILE", "SLOW_CONNECTION"],
fallbackMode: 0, // show first frame as static image
},
}).onScroll({ scrub: true }); Common Patterns
Pinned Product Spin
Pin a section and scrub a 360° product rotation as the user scrolls through it.
Motion("product-360", "canvas.product", {
imageSequence: {
canvas: "canvas.product",
urls: "/images/product-360/",
ext: "jpg",
totalFrames: 120,
numPadding: 4,
dpr: 1.25,
},
}).onScroll({
trigger: ".product-section",
start: "top top",
end: "+=400%",
scrub: 1,
pin: true,
}); Reverse on Leave
Play forward as the section enters, then reverse as it leaves.
Motion("hero-reveal", "canvas.hero", {
imageSequence: {
canvas: "canvas.hero",
urls: "/images/reveal/",
ext: "webp",
totalFrames: 60,
isReverse: true,
},
}).onScroll({
trigger: ".hero",
start: "top center",
end: "bottom center",
scrub: true,
}); Multiple Sequence Chapters
Drive different frame ranges in the same sequence from separate scroll sections.
// Chapter 1: assembly (frames 0–50%)
Motion("assembly", "canvas.product", {
imageSequence: {
canvas: "canvas.product",
urls: "/images/product/",
ext: "jpg",
totalFrames: 200,
frames: [0, 0.5],
},
}).onScroll({ scrub: true, start: "top top", end: "center top" });
// Chapter 2: detail reveal (frames 50–100%)
Motion("detail", "canvas.product", {
imageSequence: {
canvas: "canvas.product",
urls: "/images/product/",
ext: "jpg",
totalFrames: 200,
frames: [0.5, 1],
},
}).onScroll({ scrub: true, start: "center top", end: "bottom top" }); Related: Scroll Trigger · Scroll Trigger Advanced