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.

imageSequence is not a property of the core SDK’s AnimationConfig. The Motion.page Builder owns the image loading runtime and connects it to generated SDK timelines with onUpdate. In handwritten SDK code, preload and draw the frames yourself, then use the supported onUpdate callback shown below.

HTML Setup

Give the canvas a stable CSS size and place it inside the section that will drive the scroll range:

html
<section class="product-section">
  <canvas
    class="sequence"
    width="1920"
    height="1080"
    aria-label="Product rotation"
  ></canvas>
</section>
css
.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:

scroll-sequence.ts
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:

typescript
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:

typescript
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:

typescript
const reversedProgress = 1 - progress;

Image URL Pattern

Build predictable filenames from the frame index:

typescript
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() or createImageBitmap() moves decode work ahead of the first scrub.
  • Preload near the viewport. An IntersectionObserver can 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:

typescript
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.