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:

  1. First frame — loaded and drawn immediately on mount so the canvas is never blank.
  2. Priority queue — the first 25% of frames load sequentially for a fast initial scrub.
  3. Remaining frames — loaded after the priority queue completes, or deferred until the canvas enters the viewport when isIntersectionObserver is 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:

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

scroll-sequence.ts
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,
});

imageSequence is a root-level property on the animation config — not placed inside from or to. Standard from/to properties are not needed; the timeline length is driven by the trigger’s scroll range.


Image URL Pattern

Image filenames are constructed from three properties:

plaintext
${urls}${paddedIndex}.${ext}
// e.g. /images/sequence/0001.jpg
  • urls — the base directory path, including a trailing slash
  • numPadding — zero-padding width for the index (default 40001, 0002, …)
  • ext — file extension

Supported formats:

FormatExtensionAlpha channel
JPEGjpg❌ — opaque canvas, best performance
PNGpng✅ — alpha canvas enabled automatically
WebPwebp✅ — alpha canvas enabled automatically
AVIFavif✅ — 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

OptionTypeDefaultDescription
canvasstringCSS selector for the target <canvas> element
urlsstringBase path for image files (directory URL, trailing slash required)
extstring"jpg"Image file extension (jpg | png | webp | avif)
totalFramesnumberTotal number of frames in the sequence
numPaddingnumber4Zero-padding width for filenames (40001.jpg)
imageFitnumber0How the image fits the canvas: 0 = cover, 1 = contain, 2 = fill
skipFramesnumber0Skip every Nth frame to reduce image count (0 = none, 220)
frames[number, number][0, 1]Restrict playback to a fraction of the sequence (0–1 normalized range)
isReversebooleanfalsePlay the sequence in reverse direction
isIntersectionObserverbooleanfalseDefer non-priority image loading until canvas enters the viewport
intersectionObserverMarginnumber0Viewport offset (%) for intersection observer trigger
dprnumber1.25Device pixel ratio multiplier for canvas resolution
isDebugbooleanfalseLog debug information to the console
whenDisabledstring[][]Conditions under which to disable the sequence and apply fallback
fallbackModenumber0Fallback strategy when a disable condition is met
smallImagesRootstring""Alternative image base path for viewports narrower than 768px
mediumImagesRootstring""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. Requires png, webp, or avif.
  • Fill (2) — stretches the image to exactly match canvas dimensions.
typescript
// 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 — use png, webp, or avif. Using jpg with 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.

scroll-spin.ts
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.

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

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

PropertyViewport widthFallback
smallImagesRoot< 768pxurls
mediumImagesRoot< 1024pxurls
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 jpg for opaque sequences — no alpha overhead, smallest file size.
  • Enable isIntersectionObserver for sequences below the fold — remaining frames won’t load until the canvas enters the viewport.
  • Use skipFrames to reduce total loaded images. skipFrames: 2 loads 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 dpr on mobile to reduce canvas buffer size. dpr: 1 uses native device pixels; dpr: 1.25 sharpens slightly at a small memory cost.
  • Keep frame count reasonable — 60–180 frames covers most use cases. Higher counts rarely improve perceived smoothness.
typescript
// 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:

ValueCondition
"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:

ValueBehaviour
0Display the first frame as a static <img> (default)
4Display the last frame as a static <img>
2Remove the <canvas> element from the DOM
3Leave the canvas empty
5No fallback — do nothing
typescript
// 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.

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

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

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