Apple’s product pages use a distinctive technique: as you scroll, a product rotates, unfolds, or transforms through a sequence of pre-rendered frames. It looks like video but responds to scroll position, in both directions, with no playback lag. The mechanism is a canvas element, a numbered set of images, and GSAP ScrollTrigger driving a single number.

Here is how to build it, including the parts that decide whether it feels premium or janky: preloading strategy, frame sizing, high-density rendering, and what reduced-motion visitors see instead.

What you’ll build

A scroll-pinned canvas that draws image frames as the user scrolls. Scroll down, frames advance. Scroll up, they reverse. The container stays pinned until all frames have played.

This version handles staged frame preloading, HiDPI canvas rendering, scroll-to-frame mapping, a no-JavaScript fallback image, and a proper reduced-motion mode that shows a representative still rather than nothing.

Live, interactive preview. Scroll inside the frame to advance the frames. Get the full code →

Step 1: Add GSAP and ScrollTrigger

Load GSAP and the ScrollTrigger plugin from a CDN. No install or build step required.

<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>

Step 2: The HTML and CSS

The markup is a container section, a real fallback image, and the canvas.

<section class="sequence-container">
  <img class="sequence-fallback" src="/frames/frame-045.webp"
       alt="The product rotating to show its side profile">
  <canvas class="sequence-canvas" aria-hidden="true"></canvas>
</section>

That <img> is doing real work. It is what visitors see before the frames finish downloading, and it is the whole experience if JavaScript never runs. Pick a frame from the middle of the sequence, where the subject is most recognisable, and write a genuine alt description of it. The canvas is decorative once the image is there, so it takes aria-hidden="true".

The container fills the viewport so the pinned canvas occupies the full screen while frames play.

.sequence-container {
  position: relative;
  width: 100%;
  height: 100vh;
  overflow: hidden;
}

.sequence-fallback,
.sequence-canvas {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  display: block;
  transition: opacity 0.3s ease;
}

.sequence-fallback {
  object-fit: cover;
}

.sequence-canvas {
  opacity: 0;
}

/* Swap the still for the canvas once frames are ready to draw. */
.sequence-container.is-ready .sequence-fallback { opacity: 0; }
.sequence-container.is-ready .sequence-canvas { opacity: 1; }

@media (prefers-reduced-motion: reduce) {
  .sequence-fallback,
  .sequence-canvas { transition: none; }
}

Note that the canvas gets no object-fit. Its bitmap is sized in JavaScript to match its box exactly, so any fitting rule would only fight that.

Step 3: Preload the frames

Image sequences are a set of numbered files, typically frame-001.webp through frame-090.webp. The naive approach requests all of them at once and waits for the last one. That works, but it means a long blank wait before anything is interactive, and on a slow connection it means the user scrolls past a static poster and never sees the effect at all.

A better strategy loads in two passes. First a coarse pass of every eighth frame, which makes the sequence scrubbable almost immediately. Then everything else in the background at low priority, sharpening the playback as it arrives.

Start with a primitive that loads one frame and waits for it to decode:

const frameCount = 90;
const frames = new Array(frameCount);

function frameUrl(index) {
  return `/frames/frame-${String(index + 1).padStart(3, '0')}.webp`;
}

function loadFrame(index, priority) {
  if (frames[index]) return Promise.resolve(frames[index]);

  const img = new Image();
  img.decoding = 'async';
  img.fetchPriority = priority || 'auto';
  img.src = frameUrl(index);

  // decode() resolves once the pixels are ready to draw, not just downloaded.
  const ready = img.decode
    ? img.decode()
    : new Promise((resolve, reject) => {
        img.onload = resolve;
        img.onerror = reject;
      });

  return ready.then(() => (frames[index] = img)).catch(() => null);
}

Two details matter here. img.decode() resolves only once the browser has decoded the image, so the first drawImage call does not stall the main thread mid-scroll. And fetchPriority lets you tell the browser which frames it should fetch first, which is the whole point of a staged load.

Now the staged preload itself:

function preloadFrames(onProgress) {
  const stride = 8;
  const anchors = [];
  for (let i = 0; i < frameCount; i += stride) anchors.push(i);
  if (anchors[anchors.length - 1] !== frameCount - 1) anchors.push(frameCount - 1);

  let loaded = 0;
  function track(index, priority) {
    return loadFrame(index, priority).then((img) => {
      loaded += 1;
      onProgress(loaded / frameCount);
      return img;
    });
  }

  const anchorPass = Promise.all(anchors.map((i) => track(i, 'high')));

  // Keep filling in the gaps behind the user's back.
  anchorPass.then(() => {
    for (let i = 0; i < frameCount; i += 1) {
      if (!frames[i]) track(i, 'low');
    }
  });

  return anchorPass;
}

Because playback can now start with gaps in the array, drawing needs to fall back to the closest frame that has actually loaded:

function nearestLoaded(index) {
  if (frames[index]) return frames[index];
  for (let offset = 1; offset < frameCount; offset += 1) {
    if (frames[index - offset]) return frames[index - offset];
    if (frames[index + offset]) return frames[index + offset];
  }
  return null;
}

The result is a sequence that starts coarse and never blank, then quietly sharpens. If your sequence cannot tolerate coarse playback, wait for the full preloadFrames chain instead and show the progress percentage while it runs.

Step 4: Set up the canvas

The canvas bitmap needs to match its display size in real device pixels, or frames look soft on high-density screens. Cap the ratio at 2: beyond that you are allocating three or four times the memory for a difference nobody can see, and on a 3x phone that difference is the one that drops frames.

const container = document.querySelector('.sequence-container');
const canvas = container.querySelector('.sequence-canvas');
const ctx = canvas.getContext('2d');

let lastDrawn = -1;
let lastImage = null;

function sizeCanvas() {
  const rect = container.getBoundingClientRect();
  const dpr = Math.min(window.devicePixelRatio || 1, 2);

  canvas.width = Math.max(1, Math.round(rect.width * dpr));
  canvas.height = Math.max(1, Math.round(rect.height * dpr));
  canvas.style.width = rect.width + 'px';
  canvas.style.height = rect.height + 'px';

  drawFrame(lastDrawn < 0 ? 0 : lastDrawn, true);
}

Measure the container, not the canvas. The canvas has an explicit pixel size assigned to it, so measuring it would just read back your own last answer.

Drawing uses a source-rectangle crop rather than scaling an oversized image past the canvas edges. The browser then only samples the pixels that end up visible.

function drawFrame(index, force) {
  index = Math.round(index);

  const img = nearestLoaded(index);
  if (!img) return;
  if (!force && index === lastDrawn && img === lastImage) return;

  lastDrawn = index;
  lastImage = img;

  // naturalWidth for <img>, width for ImageBitmap.
  const sourceW = img.naturalWidth || img.width;
  const sourceH = img.naturalHeight || img.height;
  const canvasRatio = canvas.width / canvas.height;
  const imageRatio = sourceW / sourceH;

  let sw = sourceW;
  let sh = sourceH;
  let sx = 0;
  let sy = 0;

  if (imageRatio > canvasRatio) {
    sw = sourceH * canvasRatio;
    sx = (sourceW - sw) / 2;
  } else {
    sh = sourceW / canvasRatio;
    sy = (sourceH - sh) / 2;
  }

  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.drawImage(img, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height);
}

Everything here works in device pixels, which is why there is no ctx.scale() call. That keeps the maths in one coordinate system and avoids a transform that silently resets every time you assign canvas.width.

The guard at the top skips redundant draws. During a scrub, GSAP fires updates far more often than the frame index actually changes, and repainting the same image sixty times a second for nothing is the single easiest win in this effect. Comparing the resolved image as well as the index means a coarse anchor frame still gets replaced once its exact neighbour finishes loading.

Step 5: Connect to ScrollTrigger

GSAP animates a plain object’s frame property from 0 to frameCount - 1, and each update draws the matching image. Wrapping it in gsap.matchMedia() gives you two separate behaviours and correct teardown if the user changes their motion preference without reloading.

gsap.registerPlugin(ScrollTrigger);

const playhead = { frame: 0 };
const mm = gsap.matchMedia();

// Reduced motion: one representative still, no pin, no bulk download.
mm.add('(prefers-reduced-motion: reduce)', () => {
  const keyframe = Math.round((frameCount - 1) * 0.5);
  loadFrame(keyframe, 'high').then((img) => {
    if (!img) return;
    sizeCanvas();
    drawFrame(keyframe, true);
    container.classList.add('is-ready');
  });
});

// Full motion: staged preload, then pin and scrub.
mm.add('(prefers-reduced-motion: no-preference)', () => {
  let tween = null;
  let resizeTimer = 0;
  let cancelled = false;

  function handleResize() {
    clearTimeout(resizeTimer);
    resizeTimer = setTimeout(() => {
      sizeCanvas();
      ScrollTrigger.refresh();
    }, 160);
  }

  function showProgress(ratio) {
    // Wire this to a loading readout if you have one.
    container.style.setProperty('--load-progress', Math.round(ratio * 100) + '%');
  }

  preloadFrames(showProgress).then(() => {
    if (cancelled) return;

    sizeCanvas();
    drawFrame(0, true);
    container.classList.add('is-ready');
    window.addEventListener('resize', handleResize);

    tween = gsap.to(playhead, {
      frame: frameCount - 1,
      ease: 'none',
      snap: { frame: 1 },
      scrollTrigger: {
        trigger: container,
        start: 'top top',
        end: () => '+=' + Math.round(window.innerHeight * 3),
        pin: true,
        scrub: 0.5,
        invalidateOnRefresh: true
      },
      onUpdate: () => drawFrame(playhead.frame)
    });

    ScrollTrigger.refresh();
  });

  return () => {
    cancelled = true;
    clearTimeout(resizeTimer);
    window.removeEventListener('resize', handleResize);
    if (tween) {
      if (tween.scrollTrigger) tween.scrollTrigger.kill();
      tween.kill();
    }
  };
});

A few properties are worth understanding:

  • pin: true locks the container in place while scrolling. The page will not continue until the sequence has played.
  • scrub: 0.5 adds half a second of catch-up smoothing so frame changes feel fluid rather than stepped. scrub: true maps scroll to progress exactly, which can look jittery on a trackpad with a short sequence.
  • snap: { frame: 1 } keeps the animated value on whole frame numbers, so you never ask the array for index 42.7.
  • end is a function, not a fixed pixel count. Combined with invalidateOnRefresh: true, the pinned distance is recalculated whenever ScrollTrigger refreshes, so rotating a phone or resizing a window does not leave the sequence finishing halfway down the pin.

That end value is also your frame budget. Three viewport heights on a 900px tall window is 2700px of scroll, which across 90 frames is 30px of scroll per frame. Under roughly 25px per frame the playback starts skipping visibly on a fast flick; over roughly 50px it feels like the page has stalled.

Two details that are easy to miss. The ScrollTrigger.refresh() call after the preload matters because the layout may have shifted while frames were downloading, and a pin measured against stale positions starts in the wrong place. And the cleanup function returned from mm.add has to kill the tween by hand: it is created inside an async continuation, which runs after GSAP has stopped recording work into the context automatically.

Faster decoding with createImageBitmap

If your sequence is long or your frames are large, createImageBitmap is worth the extra code. It decodes off the main thread and hands back an object that is already in the format the canvas wants, so drawImage becomes close to a memory copy.

async function loadFrameBitmap(index, targetWidth) {
  const response = await fetch(frameUrl(index));
  const blob = await response.blob();

  try {
    return await createImageBitmap(blob, {
      resizeWidth: targetWidth,
      resizeQuality: 'high'
    });
  } catch (error) {
    // Not every browser implements the resize options.
    return createImageBitmap(blob);
  }
}

The catch is memory. An ImageBitmap holds uncompressed pixels, so a 1920x1080 frame costs roughly 8MB of RAM regardless of how small its WebP file was. Ninety of those is well over half a gigabyte, which a phone will not tolerate.

That is exactly why the resizeWidth option is the interesting part: decode straight down to the width the canvas will actually draw at, and both the memory and the per-frame sampling cost fall with it. Feature-detect with typeof createImageBitmap === 'function', keep the plain Image path as the fallback, and call bitmap.close() on every frame when you tear the effect down so the memory is released rather than waiting on garbage collection.

For very heavy sequences the next step is an OffscreenCanvas driven from a worker, which moves the drawing off the main thread too. That is a larger architectural change and rarely necessary once the frames are sized correctly.

Step 6: Preparing your frames

The code is the easy part. Preparing the frames decides how the effect actually performs.

Exporting frames

Render a numbered sequence from After Effects, Blender, or any 3D tool. To pull frames from an existing video, sample at a fixed rate rather than grabbing the first N frames:

ffmpeg -i input.mp4 -vf "fps=20,scale=1600:-2" -c:v libwebp -quality 80 frames/frame-%03d.webp

fps=20 spreads the frames evenly across the whole clip, which -vframes 60 does not: that flag takes the first sixty frames and stops. The -2 in the scale filter keeps the height proportional and even.

Sizing and format

  • Size frames to the largest box they will actually be drawn into, then stop. A full-bleed hero on a 1440px layout technically wants 2880px frames at 2x, but ninety of those is an unaffordable download. Capping the set at 1600 to 1920px wide and accepting slight softness on the sharpest displays is the trade every production sequence makes.
  • Ship a second, smaller set for phones and pick the folder at runtime from window.innerWidth * devicePixelRatio. This is the single biggest mobile win available, and it costs one export preset.
  • WebP at quality 80 is the default choice. AVIF compresses better but decodes more slowly, and decode speed is what you are trading in a sequence that has to decode dozens of images.
  • Keep every frame at identical dimensions and zero-pad the numbers: frame-001.webp, frame-002.webp.

Weight

File size depends far more on content than on any rule of thumb. A rendered product on a plain background can land near 40 to 80KB per frame at 1600px wide, while a full photographic scene can be three or four times that. Export ten representative frames, measure them, and multiply. If the total crosses a few megabytes, cut the frame count before you cut the quality: viewers notice a soft frame far less than they notice a sequence that steps.

Image sequence or scrubbed video?

Both approaches produce scroll-scrubbed footage, and they fail in different places.

Choose an image sequence when you need exact, reversible access to every frame, when the sequence composites with masks or canvas effects, or when the shot is short and the subject matters more than the file size. Random access is guaranteed because every frame is its own file.

Choose a scrubbed video when the clip runs long enough that a frame set becomes hundreds of files and many megabytes. The tradeoff is seeking: setting currentTime on a compressed video only lands smoothly if the file was encoded with a very dense keyframe interval, and that re-encode inflates the file until much of its size advantage is gone. Mobile Safari in particular is fussy about scrubbing video reliably.

A useful dividing line: under about a hundred frames with precision that matters, use images. Longer than that, with approximate scrub acceptable, use video. The Scroll Video Scrub effect takes the video route, driving a real video playhead from scroll while a horizontal chapter track pans across the pinned footage.

That covers the basics

The container pins, frames draw to canvas as you scroll, the preload starts coarse and sharpens, and both reduced-motion and no-JavaScript visitors get a real image instead of an empty box. You can drop this into any page and have a working scroll-driven image sequence.


Want more control?

The Scroll Image Sequence effect in the GSAP Vault library builds on this same core technique with the parts a basic version leaves out:

  • Staged preloading with a live percentage readout and a nearest-frame fallback, so scrubbing never shows a gap while the set finishes downloading
  • Data attribute configuration for frame path, count, zero padding, extension, scrub amount, and separate desktop and mobile scroll distances, with no JavaScript to edit
  • HiDPI canvas capped at 2x with a debounced resize that re-measures and refreshes ScrollTrigger together
  • An expanding rounded mask that opens from a framed viewport to full bleed as the frames play
  • Timeline-synchronised chapter crossfades with a persistent final state, for scroll-driven narration over the footage
  • Multiple sequences on one page with independent frame sets sharing a single image cache
  • Representative-frame reduced motion plus a real no-JavaScript fallback image, both built in
  • Single-clock Lenis integration with complete teardown

View the full effect with demos and options

If the sequence is your opening hero rather than a chapter inside the page, Scroll Sequence Hero Transition handles the harder version of the same idea: the finished sequence lifts away as a moving sheet and resolves into real page content, so the pin releases into ordinary scrolling instead of ending on another frame.

More in the same territory: GSAP scroll animations and GSAP image effects.