To make an image sequence scroll animation with GSAP, export the animation as numbered image frames, draw them onto a <canvas>, and let ScrollTrigger scrub a single frame number from the first frame to the last while the section is pinned. Every scroll update draws the matching image, so the footage plays forward as you scroll down and reverses as you scroll up, with no playback lag. It is the technique behind Apple’s product pages, where a device rotates or unfolds in step with the scroll.

The steps below build a working version, then cover the parts that decide whether it feels premium or janky: staged preloading, frame sizing, high-density rendering, separate frame sets for phones, what reduced-motion visitors see, and fixes for the bugs that show up once it is live.

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;
  height: 100svh; /* ignores the mobile address bar where supported */
  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;
  let lastWidth = window.innerWidth;

  function handleResize() {
    // Mobile toolbars change the height on every scroll direction change.
    // Only a width change (rotation, window resize) needs a re-measure.
    if (window.innerWidth === lastWidth) return;
    lastWidth = window.innerWidth;
    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) + '%');
  }

  // Three screens of scroll on desktop, two on phones.
  function scrollLengths() {
    return window.innerWidth < 768 ? 2 : 3;
  }

  preloadFrames(showProgress).then(() => {
    // Nothing loaded: leave the fallback image in place rather than
    // pinning an empty canvas over it.
    if (cancelled || !nearestLoaded(0)) 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 * scrollLengths()),
        pin: true,
        scrub: 0.5,
        invalidateOnRefresh: true
      },
      onUpdate: () => drawFrame(playhead.frame)
    });

    ScrollTrigger.sort();
    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. The ScrollTrigger.sort() before it puts this late-created trigger back in page order, so any triggers further down the page account for the pin spacing it adds. 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.

Not every ffmpeg build includes the libwebp encoder; some package manager builds leave it out and fail with “Unknown encoder”. In that case export PNG frames from ffmpeg and convert them with Google’s cwebp tool (cwebp -q 80 in.png -o out.webp).

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 it at runtime (the next section shows how). 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.

Responsive image sequences: frame sets, pixel ratio and phone memory

A sequence that runs smoothly on a desktop can reload the tab on a phone. The cause is rarely the download; it is memory. Every decoded frame costs its width times its height times four bytes, whatever its file size was, and mobile browsers reload a tab that runs past their memory budget. To the visitor that looks like the page crashed.

The arithmetic is worth doing once. A 1600x900 frame decodes to about 5.8MB. Ninety of those is over 500MB if the browser keeps them all decoded, which desktop Chrome may tolerate and a mid-range phone will not. The same sequence at 960x540 and 45 frames needs about 93MB, roughly a sixth. Browsers do manage decoded image memory themselves and can discard frames under pressure, but a discarded frame has to be decoded again mid-scrub, which is a stutter you cannot control.

Pick a frame set per breakpoint

Export two sets and choose one when the page loads, based on how many device pixels the canvas will actually cover:

const container = document.querySelector('.sequence-container');

const FRAME_SETS = [
  { maxPixels: 960, path: '/frames/sm', count: 45 },
  { maxPixels: Infinity, path: '/frames/lg', count: 90 }
];

function pickFrameSet() {
  const dpr = Math.min(window.devicePixelRatio || 1, 2);
  const needed = container.getBoundingClientRect().width * dpr;
  return FRAME_SETS.find((set) => needed <= set.maxPixels);
}

const frameSet = pickFrameSet();
const frameCount = frameSet.count;

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

These lines replace the fixed frameCount and frameUrl from Step 3, and the container lookup moves up from Step 4 because the choice depends on it. Everything else in the tutorial reads frameCount and frameUrl, so nothing further changes.

A few rules keep this honest:

  • Measure the box, not the screen. A sequence in a half-width column on a desktop needs far fewer pixels than one that bleeds edge to edge. Using the container width means the set follows your layout.
  • Cap devicePixelRatio at 2 here too. Many phones report 3. Choosing frames for 3x means shipping sharper frames than the canvas will ever draw, since Step 4 already caps the canvas at 2x.
  • The small set can have fewer frames. Exporting every other frame halves both the download and the memory, and the shorter mobile scroll distance (two screens rather than three in Step 5) keeps the scroll-per-frame budget in the same 25 to 50 pixel range.
  • Choose once per page load. Swapping sets when a phone rotates means downloading a second sequence mid-scroll. Keep the first choice; the canvas resize in Step 4 handles the new shape.

Keep mobile resizes cheap

Mobile browsers change the viewport height every time the address bar shows or hides, and they fire resize when they do. Re-measuring the canvas and refreshing ScrollTrigger on each of those events is a visible hitch in the middle of a scrub. That is why the Step 5 handler returns early when only the height changed, and why the container uses 100svh, a height unit that does not move with the toolbar. If the page has other ScrollTriggers, ScrollTrigger.config({ ignoreMobileResize: true }) stops small vertical resizes on touch devices from triggering a full refresh.

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.

Troubleshooting: blank canvas, flicker and a pin in the wrong place

Most image sequence bugs fall into a handful of patterns. Check these before rewriting anything.

The canvas stays blank, or only the fallback image shows

  • The frames are 404ing. loadFrame resolves failed frames as null so one bad file cannot break playback, which also means a wrong path fails silently. Open the Network panel and filter by webp: a column of red rows means frameUrl does not match your file names or folder.
  • The container had no size when the canvas was measured. If the section starts inside a hidden tab, a collapsed accordion or a display: none parent, getBoundingClientRect() returns zero and the canvas bitmap is one pixel wide. Call sizeCanvas() and ScrollTrigger.refresh() when the section becomes visible.
  • The canvas covers the fallback with nothing. The canvas is transparent until is-ready is added. If you adapt the code, keep the nearestLoaded(0) check in Step 5: without it, a sequence whose frames all failed still swaps the fallback image out for an empty canvas and pins a blank screen.

Flicker or a flash between frames

  • Transparent frames. clearRect wipes the canvas before each draw, so frames with an alpha channel briefly show whatever is behind the canvas. Export opaque frames, or give the container a background that matches the footage.
  • Frames that were not decoded ahead of time. Drawing an image the browser has not decoded forces a synchronous decode in the middle of the scroll. The decode() call in Step 3 exists to prevent this; keep it if you adapt the loader.
  • Frames of different sizes. A single frame exported at a different resolution crops differently and jumps. Keep every frame identical in dimensions.

The pin starts early, ends late, or overlaps the next section

  • The layout moved after ScrollTrigger measured it. ScrollTrigger refreshes on the window load event, but images that load lazily and web fonts that swap in later can still push content down afterwards. Give images above the sequence width and height attributes so they reserve space, and call ScrollTrigger.refresh() once late content has settled, for example after document.fonts.ready resolves.
  • Triggers were created out of page order. This sequence creates its trigger after the preload, so it can be the last one created while sitting near the top of the page. Triggers further down then ignore the spacing its pin adds. The ScrollTrigger.sort() call in Step 5 fixes this; so does setting refreshPriority on triggers you create by hand.
  • A parent has a transform. A pinned element uses position: fixed, and a transformed ancestor turns that into positioning relative to the ancestor. Remove the transform from the parent, or move the sequence out from under it.

Playback steps or jitters instead of flowing

  • Check the scroll-per-frame budget from Step 5. Under about 25 pixels of scroll per frame, a fast flick skips frames; add frames or shorten the pin.
  • Raise scrub from 0.5 towards 1 for more catch-up smoothing on trackpads.
  • If you run a smooth scroll library, make sure ScrollTrigger is updated from the same animation frame as the scroller, or the two fight and the frame index trembles.

That covers the basics

The container pins, frames draw to canvas as you scroll, the preload starts coarse and sharpens, phones get a smaller frame set, 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 image sequence scroll animation.


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, GSAP image effects, or the full GSAP effects catalogue.