Image Sequence
Drive frame-by-frame canvas sequences with Motion.page timeline progress.
An image sequence maps timeline progress from 0 to 1 onto numbered image frames and draws the current frame to a <canvas>. It is a common pattern for scroll-driven product spins, cinematic reveals, and 3D rotations.
imageSequenceis not a property of the core SDK’sAnimationConfig. The Motion.page Builder owns the image loading runtime and connects it to generated SDK timelines withonUpdate. In handwritten SDK code, preload and draw the frames yourself, then use the supportedonUpdatecallback shown below.
HTML Setup
Give the canvas a stable CSS size and place it inside the section that will drive the scroll range:
<section class="product-section">
<canvas
class="sequence"
width="1920"
height="1080"
aria-label="Product rotation"
></canvas>
</section>.product-section {
min-height: 300vh;
}
.sequence {
display: block;
width: 100%;
aspect-ratio: 16 / 9;
}Basic Usage
Preload the numbered files, draw the first frame, and let AnimationConfig.onUpdate select the frame for the current timeline progress:
import { Motion } from "@motion.page/sdk";
const canvas = document.querySelector<HTMLCanvasElement>("canvas.sequence")!;
const context = canvas.getContext("2d")!;
const urls = Array.from(
{ length: 120 },
(_, index) => "/images/product/" + String(index + 1).padStart(4, "0") + ".jpg"
);
const loadImage = (src: string) =>
new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = reject;
image.src = src;
});
const frames = await Promise.all(urls.map(loadImage));
function drawFrame(progress: number) {
const index = Math.round(progress * (frames.length - 1));
const image = frames[index];
context.clearRect(0, 0, canvas.width, canvas.height);
context.drawImage(image, 0, 0, canvas.width, canvas.height);
}
drawFrame(0);
const playhead = { progress: 0 };
Motion("product-spin", playhead, {
to: { progress: 1 },
duration: 1,
ease: "none",
onUpdate: drawFrame,
}).onScroll({
target: ".product-section",
start: "top top",
end: "+=300%",
scrub: true,
pin: true,
}); The preview generates its frames procedurally so it is self-contained. A production sequence uses the same progress-to-index mapping with decoded image frames.
Fit Frames Without Stretching
The basic example fills the canvas exactly. For mixed aspect ratios, use a cover-style draw helper:
function drawCover(
context: CanvasRenderingContext2D,
image: CanvasImageSource,
width: number,
height: number
) {
const source = image as { width: number; height: number };
const imageWidth = source.width;
const imageHeight = source.height;
const scale = Math.max(width / imageWidth, height / imageHeight);
const drawWidth = imageWidth * scale;
const drawHeight = imageHeight * scale;
context.clearRect(0, 0, width, height);
context.drawImage(
image,
(width - drawWidth) / 2,
(height - drawHeight) / 2,
drawWidth,
drawHeight
);
}Use Math.min(...) instead of Math.max(...) for contain behavior.
Frame Ranges and Reverse
Map SDK progress into a normalized sub-range before choosing the image:
const start = 0.25;
const end = 0.75;
function drawPartialSequence(progress: number) {
const rangedProgress = start + (end - start) * progress;
const index = Math.round(rangedProgress * (frames.length - 1));
drawCover(context, frames[index], canvas.width, canvas.height);
}For reverse playback, map 1 - progress instead:
const reversedProgress = 1 - progress;Image URL Pattern
Build predictable filenames from the frame index:
const urls = Array.from({ length: 120 }, (_, index) => {
const frame = String(index + 1).padStart(4, "0");
return `/images/product/${frame}.webp`;
});The example produces 0001.webp through 0120.webp. Keep all frames at the same dimensions and compression settings to avoid visible jumps.
Loading and Performance
- Draw frame 1 as soon as it loads. Do not leave the canvas blank while the rest of the sequence downloads.
- Decode before interaction.
HTMLImageElement.decode()orcreateImageBitmap()moves decode work ahead of the first scrub. - Preload near the viewport. An
IntersectionObservercan delay the full queue until the section approaches. - Use responsive frame sets. Lower-resolution images save bandwidth and canvas memory on phones.
- Cap canvas DPR.
Math.min(devicePixelRatio, 2)avoids oversized drawing buffers on high-density screens. - Skip frames when necessary. Loading every second frame often preserves the feel while halving requests.
- Prefer WebP or AVIF. Use PNG only when lossless alpha is required; JPEG remains useful for opaque photographic frames.
Builder-Generated Sequences
The Motion.page Builder provides upload/transcode tools, prioritized loading, responsive roots, fallback modes, and its own Image Sequence player. During export, the SDK generator links that player to the timeline with a timeline-level callback equivalent to:
Motion("product-spin", [/* timeline entries */])
.onUpdate((progress) => {
window._mp_sequence_product?.handleUpdate(progress);
})
.onScroll({ scrub: true });That bridge is generated code, not an imageSequence field accepted by the core SDK. See Image Sequence in the Builder for the asset pipeline and controls.
Related
- Scroll Trigger — map sequence progress to scrolling
- Scroll Trigger Advanced — pin the canvas across a longer scroll range
- Timeline Control — react to timeline-level progress and lifecycle events