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. Here’s how to build it with GSAP ScrollTrigger and a canvas element.

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 basic version handles frame preloading, canvas rendering, scroll-to-frame mapping, and reduced motion support.

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.14.2/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/ScrollTrigger.min.js"></script>

Step 2: The HTML and CSS

The markup is minimal: a container section and a canvas element inside it.

<section class="sequence-container">
  <canvas id="sequence-canvas"></canvas>
</section>

The container needs to fill the viewport so the pinned canvas occupies the full screen while frames play.

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

#sequence-canvas {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  object-fit: contain;
}

Step 3: Preload the frames

Image sequences are a set of numbered files, typically named something like frame-001.webp through frame-060.webp. Every frame needs to be fully downloaded before playback can start. Otherwise you get blank flashes as the user scrolls past frames that haven’t loaded yet.

const frameCount = 60;
const frames = [];
let loadedCount = 0;

function preloadFrames() {
  return new Promise((resolve) => {
    for (let i = 0; i < frameCount; i++) {
      const img = new Image();
      img.onload = () => {
        loadedCount++;
        if (loadedCount === frameCount) resolve();
      };
      img.src = `/frames/frame-${String(i + 1).padStart(3, '0')}.webp`;
      frames[i] = img;
    }
  });
}

This creates an Image object for each frame, kicks off parallel downloads, and resolves the promise once every frame has loaded.

Format tip: Use WebP for your frames. A 60-frame sequence at 1920x1080 in WebP runs roughly 3-5MB total, compared to 15-20MB in PNG. That difference matters on mobile connections.

Step 4: Set up the canvas

The canvas needs to match its display size in actual pixels, accounting for high-density screens. Without the devicePixelRatio scaling, frames look blurry on Retina displays.

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

function resizeCanvas() {
  canvas.width = canvas.offsetWidth * window.devicePixelRatio;
  canvas.height = canvas.offsetHeight * window.devicePixelRatio;
  ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
}

function drawFrame(index) {
  const img = frames[index];
  if (!img) return;

  const canvasW = canvas.offsetWidth;
  const canvasH = canvas.offsetHeight;

  // Cover-fit the image to the canvas
  const imgRatio = img.width / img.height;
  const canvasRatio = canvasW / canvasH;

  let drawW, drawH, drawX, drawY;
  if (imgRatio > canvasRatio) {
    drawH = canvasH;
    drawW = canvasH * imgRatio;
    drawX = (canvasW - drawW) / 2;
    drawY = 0;
  } else {
    drawW = canvasW;
    drawH = canvasW / imgRatio;
    drawX = 0;
    drawY = (canvasH - drawH) / 2;
  }

  ctx.clearRect(0, 0, canvasW, canvasH);
  ctx.drawImage(img, drawX, drawY, drawW, drawH);
}

The drawFrame function uses cover-fit logic so the image fills the canvas without distortion, cropping equally from both sides when aspect ratios don’t match.

Step 5: Connect to ScrollTrigger

This is where everything comes together. GSAP animates a plain object’s index property from 0 to frameCount - 1, and on each update, the corresponding frame is drawn to the canvas.

async function init() {
  if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
    // Show first frame statically for users who prefer reduced motion
    await preloadFrames();
    resizeCanvas();
    drawFrame(0);
    return;
  }

  gsap.registerPlugin(ScrollTrigger);

  await preloadFrames();
  resizeCanvas();
  drawFrame(0);

  const frameObj = { index: 0 };

  gsap.to(frameObj, {
    index: frameCount - 1,
    ease: "none",
    snap: "index",
    scrollTrigger: {
      trigger: ".sequence-container",
      start: "top top",
      end: "+=3000",
      pin: true,
      scrub: 0.5,
    },
    onUpdate: () => {
      drawFrame(Math.round(frameObj.index));
    }
  });

  // Handle resize
  window.addEventListener('resize', () => {
    resizeCanvas();
    drawFrame(Math.round(frameObj.index));
  });
}

init();

A few key properties to understand:

  • pin: true locks the container in place while scrolling. The page won’t continue until the full sequence has played.
  • scrub: 0.5 adds a half-second of smoothing so frame changes feel fluid rather than jumpy. A value of true (no smoothing) works too, but can feel jittery with fewer frames.
  • snap: "index" ensures GSAP lands on whole frame numbers, preventing half-frame renders.
  • end: "+=3000" means 3000 pixels of scroll distance to complete the sequence. Increase this for a slower, more cinematic feel. Decrease it for a faster scrub.

The reduced motion check at the top loads and displays the first frame without any scroll animation. Users who prefer reduced motion see the image content without the scrolling interaction.

Step 6: Preparing your frames

The code is the easy part. Preparing the frames takes more thought.

Exporting frames:

  • Use After Effects, Blender, or any video editor to render a numbered image sequence
  • FFmpeg can also extract frames from video: ffmpeg -i input.mp4 -vframes 60 frame-%03d.webp

Optimizing for the web:

  • WebP at 80% quality gives the best size-to-quality ratio
  • Keep all frames at the same dimensions
  • 60 frames is a solid starting point. More frames means smoother playback but a larger download
  • Name them with zero-padded numbers: frame-001.webp, frame-002.webp, and so on

Loading considerations:

  • 60 frames at ~50-80KB each is roughly 3-5MB total
  • Consider adding a loading indicator so users know something is happening while frames download
  • On slow connections, you may want to lazy-load the entire section or defer init until the container is near the viewport

That covers the basics

The container pins, frames draw to canvas as you scroll, and users who prefer reduced motion see the first frame statically. 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 features the basic version doesn’t include:

  • Automatic preload progress indicator with a percentage display that fades out on completion
  • Data attribute configuration so you can set frame path, count, padding, extension, and scrub speed from HTML without touching JavaScript
  • Responsive canvas with debounced resize that handles orientation changes and dynamic layouts cleanly
  • HiDPI rendering with a capped pixel ratio so Retina displays look crisp without over-allocating memory
  • Lenis smooth scroll integration for seamless interaction with smooth scroll libraries
  • Multiple sequences per page with independent frame sets and a shared image cache
  • Masked hero zoom demo where a rounded viewport mask expands to full-screen as frames play, similar to Apple’s product reveal style
  • Chapter story demo with timed text overlays that fade in and out at specific points in the frame sequence, creating scroll-driven narratives

View the full effect with demos and options