Scroll image sequence (Apple-style product turn)

The product that turns as you scroll is often not a video at all: it is a folder of numbered stills, painted on a canvas one at a time. A still shows the instant it is picked, in every browser, where a video has to seek and decode. Here 71 frames of NASA’s Landsat 8 model turn a full circle while the stage is pinned, and the captions follow the frame.

014 steps

Build it, step by step

  1. Step 01 · terminal

    Export the stills, numbered

    scrollFrames draws one image per frame, so the film becomes a folder of stills with a fixed-width number in each name, and a function builds each URL from its index. The frames start at 1 and the index at 0, hence i + 1. The ones running below are 71 WebP stills, 960×540, 749 KB together. They need not all arrive before the scrub starts: the first frame loads first, then the last, then the middle of every gap, and until the frame the scroll calls for arrives, the nearest loaded one shows.

    terminal6 lines
    mkdir frames
    
    # 12 stills for each second of video, 960px wide, numbered from 1:
    # frames/frame_0001.webp, frames/frame_0002.webp …
    ffmpeg -i turntable.mp4 -an -vf "fps=12,scale=960:-2" \
      -c:v libwebp -quality 80 frames/frame_%04d.webp

    No ffmpeg? The scroll image sequence generator turns a video into the same numbered frames in your browser and hands them over as a zip.

    Fig. 1.1Which frame loads when
    index 0 → frame_0001.webp70 → frame_0071.webp12345678903570above: the order the first nine are requested,six in flight at once; the rest fill the gaps
  2. Step 02 · satellite-turn.css

    Give the scroll room to a track

    The stage is position: sticky inside a track four screens tall, so it stays stuck for three screens of scroll, and that stretch is the turn: 71 frames over 300vh, about 4.3vh of scroll each. Size the canvas with CSS; scrollFrames sets its drawing buffer to that box × devicePixelRatio, capped at 2.

    satellite-turn.css10 lines
    .turn-track { position: relative; height: 400vh; }
    
    .turn-stage {
      position: sticky;
      top: 0;
      height: 100vh;
      height: 100svh;
    }
    
    .turn-film canvas { width: 100%; height: auto; aspect-ratio: 16 / 9; }
    Fig. 1.2Track and stage
    .turn-track400vhframe 1frame 71stuck300vh≈ 4.3vha frame
  3. Step 03 · SatelliteTurn.tsx

    Draw on a canvas, measuring the track

    A canvas in a sticky stage moves with the scroll while it is stuck, so measured on the canvas the window comes out wrong. triggerElement measures the track instead (a ref, read when the film starts): top top to bottom bottom is exactly the stretch the stage is stuck. fit="contain" shows the whole frame, letterboxed into the black stage; the default, cover, fills the box and crops. <ScrollFrames> renders the canvas with role="img" and stops the film, and the frames still loading, when it unmounts. Every option is a prop, each one in the docs.

    SatelliteTurn.tsx14 lines
    const track = useRef(null);
    
    <section ref={track} className="turn-track">
      <div className="turn-stage">
        <ScrollFrames
          frames={(i) => `${base}/frame_${String(i + 1).padStart(4, '0')}.webp`}
          count={71}
          fit="contain"
          triggerElement={track}
          trigger={{ start: 'top top', end: 'bottom bottom' }}
          aria-label="NASA's Landsat 8 satellite turning…"
        />
      </div>
    </section>
    Fig. 1.3Which box sets the window
    canvasmeasure the canvasmoves with the scrollcanvasmeasure the trackthe whole pindashed: the track · bracket: the window
  4. Step 04 · SatelliteTurn.tsx

    Let the frame pick the words

    onProgress reports how far through the turn the reader is, 0 to 1, and the index of the frame that calls for. Give each chapter the point it takes over from and show the last one that has started; the counter under the canvas reads the frame.

    SatelliteTurn.tsx10 lines
    const CHAPTERS = [
      { from: 0,    title: 'Landsat 8', … },
      { from: 0.4,  title: 'One wing', … },
      { from: 0.72, title: 'Two instruments', … },
    ];
    
    onProgress={(p, i) => {
      setChapter(chapterAt(p));
      setFrame(i);
    }}

    Under reduced motion scrollFrames loads only the last frame and shows it, so the chapters cannot be scrolled through; the CSS shows all of them instead.

    Fig. 1.4Chapters on the turn
    010frame 1020.4frame 29030.72frame 511frame 71the copy follows the turn, not the page
02The whole file

The complete component

Two files, ready to paste into a React or Next.js project with svg-scroll-draw installed and the frames in public/frames/landsat. The turn below them is this exact component, on 71 frames of NASA’s Landsat 8 (LDCM) model animation: NASA/Goddard Space Flight Center Conceptual Image Lab. Public domain (created by NASA). Source: NASA SVS, Wikimedia Commons.

SatelliteTurn.tsx66 lines
'use client';

import { useRef, useState } from 'react';
import { ScrollFrames } from 'svg-scroll-draw/react';
import './satellite-turn.css';

/** 71 stills, frame_0001.webp … frame_0071.webp, 960×540 each. */
const COUNT = 71;

/** What the copy says, and from which point in the turn (0–1) it says it. */
const CHAPTERS = [
  { from: 0, title: 'Landsat 8', body: 'Launched in February 2013 to photograph the land, from 705 km up.' },
  { from: 0.4, title: 'One wing', body: 'A single solar array powers it through each 99-minute orbit.' },
  { from: 0.72, title: 'Two instruments', body: 'An imager and a thermal sensor look straight down, back over the same ground every 16 days.' },
];

const chapterAt = (p: number) => CHAPTERS.reduce((at, c, i) => (p >= c.from ? i : at), 0);

/** `base`: the folder holding the frames, with no trailing slash. */
export function SatelliteTurn({ base = '/frames/landsat' }: { base?: string }) {
  const track = useRef<HTMLElement>(null);
  const [chapter, setChapter] = useState(0);
  const [frame, setFrame] = useState(0);

  return (
    <section ref={track} className="turn-track">
      <div className="turn-stage">
        <figure className="turn-film">
          {/* A new frames function each render is fine: the film is kept while
              it gives the same URLs, and rebuilt when `base` changes them. */}
          <ScrollFrames
            frames={(i) => `${base}/frame_${String(i + 1).padStart(4, '0')}.webp`}
            count={COUNT}
            fit="contain"
            // The stage is sticky, so the canvas's own box moves with the scroll.
            // Measure the track that holds the scroll room instead.
            triggerElement={track}
            trigger={{ start: 'top top', end: 'bottom bottom' }}
            onProgress={(p, i) => {
              setChapter(chapterAt(p));
              setFrame(i);
            }}
            aria-label="NASA's Landsat 8 satellite turning a full circle on a turntable as you scroll"
          />
          <figcaption className="turn-meta">
            <span>
              Landsat 8 (LDCM) model animation: NASA/Goddard Space Flight Center Conceptual Image Lab. Public domain
              (created by NASA). <a href="https://svs.gsfc.nasa.gov/10812">NASA SVS</a> ·{' '}
              <a href="https://commons.wikimedia.org/wiki/File:LDCM_spacecraft_rotating.webm">Wikimedia Commons</a>
            </span>
            <span className="turn-n" aria-hidden="true">{String(frame + 1).padStart(2, '0')} / {COUNT}</span>
          </figcaption>
        </figure>
        <ol className="turn-copy">
          {CHAPTERS.map((c, i) => (
            <li key={c.title} aria-current={i === chapter ? 'step' : undefined}>
              <span className="turn-k">0{i + 1}</span>
              <h3>{c.title}</h3>
              <p>{c.body}</p>
            </li>
          ))}
        </ol>
      </div>
    </section>
  );
}
satellite-turn.css75 lines
/* The track holds the scroll room: the stage stays stuck for its height minus
   one screen, and the turn runs exactly that long. The frames are on black, so
   the stage is too, and `fit: 'contain'` letterboxes into it without a seam. */
.turn-track {
  --ink: #ece8e1;
  --paper: #000;
  --accent: #d0a15c;
  --dim: #8a8782;
  position: relative;
  height: 400vh;
  background: var(--paper);
  color: var(--ink);
}

.turn-stage {
  position: sticky;
  top: 0;
  box-sizing: border-box;
  display: grid;
  grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr);
  align-items: center;
  gap: 48px;
  height: 100vh;
  height: 100svh;
  padding: 0 6vw;
}

.turn-film { min-width: 0; margin: 0; }
/* Size the canvas with CSS: scrollFrames sets its drawing buffer to this box. */
.turn-film canvas { display: block; width: 100%; height: auto; aspect-ratio: 16 / 9; }
/* Some of NASA's frames (24–37) crop the solar array at the top edge of the
   picture: fade the canvas's top edge into the black so it ends softly, not
   in a hard line. Frames that stop short of the edge lose next to nothing. */
.turn-film canvas {
  -webkit-mask-image: linear-gradient(to bottom, transparent 0, rgba(0, 0, 0, 0.6) 5%, #000 12%);
  mask-image: linear-gradient(to bottom, transparent 0, rgba(0, 0, 0, 0.6) 5%, #000 12%);
}

.turn-meta {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  gap: 16px;
  margin-top: 12px;
  font: 400 11px/1.5 ui-monospace, monospace;
  color: var(--dim);
}
.turn-meta a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
.turn-meta a:hover { color: var(--ink); }
.turn-n { flex: none; color: var(--accent); font-variant-numeric: tabular-nums; }

.turn-copy { display: grid; gap: 28px; margin: 0; padding: 0; list-style: none; }
/* Not the current chapter: greyed by colour, not opacity, so it keeps WCAG AA
   (#8a8782 is 5.9:1 on black, the dimmed gold 5.2:1). */
.turn-copy li { color: var(--dim); transition: color 0.3s ease; }
.turn-copy li[aria-current] { color: var(--ink); }
.turn-copy li:not([aria-current]) .turn-k { color: #9b7947; }
.turn-k { font: 600 12px/1 ui-monospace, monospace; color: var(--accent); }
.turn-copy h3 { margin: 8px 0 8px; font: 600 24px/1.2 system-ui, sans-serif; }
.turn-copy p { max-width: 34ch; margin: 0; font: 400 16px/1.6 system-ui, sans-serif; }

/* On a phone the stage still has to fit one screen: one chapter at a time. */
@media (max-width: 720px) {
  .turn-stage { grid-template-columns: minmax(0, 1fr); align-content: center; gap: 20px; padding: 0 16px; }
  .turn-copy li:not([aria-current]) { display: none; }
  .turn-copy h3 { font-size: 20px; }
}

/* Reduced motion shows the last frame only, so the chapters cannot be
   scrolled through: show them all. */
@media (prefers-reduced-motion: reduce) {
  .turn-copy li { color: var(--ink); transition: none; }
  .turn-copy li:not([aria-current]) .turn-k { color: var(--accent); }
  .turn-copy li:not([aria-current]) { display: block; }
}
Fig. 2.1this file, running — keep scrolling
Landsat 8 (LDCM) model animation: NASA/Goddard Space Flight Center Conceptual Image Lab. Public domain (created by NASA). NASA SVS · Wikimedia Commons
  1. 01

    Landsat 8

    Launched in February 2013 to photograph the land, from 705 km up.

  2. 02

    One wing

    A single solar array powers it through each 99-minute orbit.

  3. 03

    Two instruments

    An imager and a thermal sensor look straight down, back over the same ground every 16 days.

No build step

The same turn as one HTML file. scrollFrames is not in the script-tag build, so this imports the ES module from a CDN. Serve it with the stills in a frames folder beside it; unlike a video, images need no Range requests.

index.html154 lines
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Scroll image sequence: a satellite turning</title>
  <style>
    body { margin: 0; background: #000; }
    .spacer { height: 100vh; }

    /* The track holds the scroll room: the stage stays stuck for its height minus
       one screen, and the turn runs exactly that long. The frames are on black, so
       the stage is too, and `fit: 'contain'` letterboxes into it without a seam. */
    .turn-track {
      --ink: #ece8e1;
      --paper: #000;
      --accent: #d0a15c;
      --dim: #8a8782;
      position: relative;
      height: 400vh;
      background: var(--paper);
      color: var(--ink);
    }

    .turn-stage {
      position: sticky;
      top: 0;
      box-sizing: border-box;
      display: grid;
      grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr);
      align-items: center;
      gap: 48px;
      height: 100vh;
      height: 100svh;
      padding: 0 6vw;
    }

    .turn-film { min-width: 0; margin: 0; }
    /* Size the canvas with CSS: scrollFrames sets its drawing buffer to this box. */
    .turn-film canvas { display: block; width: 100%; height: auto; aspect-ratio: 16 / 9; }
    /* Some of NASA's frames (24–37) crop the solar array at the top edge of the
       picture: fade the canvas's top edge into the black so it ends softly, not
       in a hard line. Frames that stop short of the edge lose next to nothing. */
    .turn-film canvas {
      -webkit-mask-image: linear-gradient(to bottom, transparent 0, rgba(0, 0, 0, 0.6) 5%, #000 12%);
      mask-image: linear-gradient(to bottom, transparent 0, rgba(0, 0, 0, 0.6) 5%, #000 12%);
    }

    .turn-meta {
      display: flex;
      justify-content: space-between;
      align-items: flex-start;
      gap: 16px;
      margin-top: 12px;
      font: 400 11px/1.5 ui-monospace, monospace;
      color: var(--dim);
    }
    .turn-meta a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
    .turn-meta a:hover { color: var(--ink); }
    .turn-n { flex: none; color: var(--accent); font-variant-numeric: tabular-nums; }

    .turn-copy { display: grid; gap: 28px; margin: 0; padding: 0; list-style: none; }
    /* Not the current chapter: greyed by colour, not opacity, so it keeps WCAG AA
       (#8a8782 is 5.9:1 on black, the dimmed gold 5.2:1). */
    .turn-copy li { color: var(--dim); transition: color 0.3s ease; }
    .turn-copy li[aria-current] { color: var(--ink); }
    .turn-copy li:not([aria-current]) .turn-k { color: #9b7947; }
    .turn-k { font: 600 12px/1 ui-monospace, monospace; color: var(--accent); }
    .turn-copy h3 { margin: 8px 0 8px; font: 600 24px/1.2 system-ui, sans-serif; }
    .turn-copy p { max-width: 34ch; margin: 0; font: 400 16px/1.6 system-ui, sans-serif; }

    /* On a phone the stage still has to fit one screen: one chapter at a time. */
    @media (max-width: 720px) {
      .turn-stage { grid-template-columns: minmax(0, 1fr); align-content: center; gap: 20px; padding: 0 16px; }
      .turn-copy li:not([aria-current]) { display: none; }
      .turn-copy h3 { font-size: 20px; }
    }

    /* Reduced motion shows the last frame only, so the chapters cannot be
       scrolled through: show them all. */
    @media (prefers-reduced-motion: reduce) {
      .turn-copy li { color: var(--ink); transition: none; }
      .turn-copy li:not([aria-current]) .turn-k { color: var(--accent); }
      .turn-copy li:not([aria-current]) { display: block; }
    }
  </style>
</head>
<body>
  <div class="spacer"></div>

  <section class="turn-track" id="track">
    <div class="turn-stage">
      <figure class="turn-film">
        <canvas id="turn" role="img" aria-label="NASA's Landsat 8 satellite turning a full circle on a turntable as you scroll"></canvas>
        <figcaption class="turn-meta">
          <span>
            Landsat 8 (LDCM) model animation: NASA/Goddard Space Flight Center Conceptual Image Lab. Public domain
            (created by NASA). <a href="https://svs.gsfc.nasa.gov/10812">NASA SVS</a> ·
            <a href="https://commons.wikimedia.org/wiki/File:LDCM_spacecraft_rotating.webm">Wikimedia Commons</a>
          </span>
          <span class="turn-n" id="n" aria-hidden="true">01 / 71</span>
        </figcaption>
      </figure>
      <ol class="turn-copy">
        <li aria-current="step" data-from="0">
          <span class="turn-k">01</span>
          <h3>Landsat 8</h3>
          <p>Launched in February 2013 to photograph the land, from 705 km up.</p>
        </li>
        <li data-from="0.4">
          <span class="turn-k">02</span>
          <h3>One wing</h3>
          <p>A single solar array powers it through each 99-minute orbit.</p>
        </li>
        <li data-from="0.72">
          <span class="turn-k">03</span>
          <h3>Two instruments</h3>
          <p>An imager and a thermal sensor look straight down, back over the same ground every 16 days.</p>
        </li>
      </ol>
    </div>
  </section>

  <div class="spacer"></div>

  <script type="module">
    import { scrollFrames } from 'https://cdn.jsdelivr.net/npm/svg-scroll-draw@2.27.3/dist/frames/index.mjs';

    const COUNT = 71;
    const chapters = [...document.querySelectorAll('.turn-copy li')];
    const n = document.getElementById('n');
    let shown = 0;

    // frames/frame_0001.webp … frames/frame_0071.webp, beside this file
    scrollFrames(document.getElementById('turn'), {
      frames: (i) => `frames/frame_${String(i + 1).padStart(4, '0')}.webp`,
      count: COUNT,
      fit: 'contain',
      // The stage is sticky, so the canvas's own box moves with the scroll.
      // Measure the track that holds the scroll room instead.
      triggerElement: document.getElementById('track'),
      trigger: { start: 'top top', end: 'bottom bottom' },
      onProgress: (p, frame) => {
        n.textContent = `${String(frame + 1).padStart(2, '0')} / ${COUNT}`;
        const i = chapters.reduce((at, li, j) => (p >= Number(li.dataset.from) ? j : at), 0);
        if (i === shown) return;
        chapters[shown].removeAttribute('aria-current');
        chapters[i].setAttribute('aria-current', 'step');
        shown = i;
      },
    });
  </script>
</body>
</html>
035 problems

Where it goes wrong

01

The canvas stays empty

None of the URLs load. The usual cause is an off-by-one: files numbered from 1 asked for from 0, or four digits asked for three. A console warning names each frame that failed; open one of those URLs and check it is there.

02

The turn runs in the wrong stretch, or jumps after a resize

It is measuring the canvas, which moves while the stage is stuck, and a phone’s address bar showing or hiding is a resize. Measure the track with triggerElement.

03

The tab takes a lot of memory

Every frame stays decoded for the life of the instance, at width × height × 4 bytes: these 71 at 960×540 hold about 140 MB, from 749 KB of files. Make the frames no larger than the canvas shows them, and use as few as the motion needs.

04

The frames look soft, or their edges are cut off

Soft: the canvas is wider than the frames, so they are scaled up; its buffer is the box × devicePixelRatio, capped at 2. Cut off: the default fit is cover, which crops to fill the box; contain shows the whole frame.

05

The stage scrolls away instead of sticking

An ancestor has overflow: hidden, auto or scroll, and a sticky element sticks inside the nearest one of those, not the page. Remove it, or use overflow: clip, which does not create one.

18scrollFrames · triggerElement · onProgress

In a real site

The exhibit page of a satellite exhibition, on one canvas and one scrollFrames instance. 71 stills of the Landsat 8 model start turning beside the headline, travel to the middle of the screen as it scrolls away, and keep turning while the stage is pinned; the same onProgress moves the satellite and lights each part, from the solar array to the X-band antenna.

Groundtrack, an exhibition of earth-observing satellites: the first screen of its demo site

An exhibition of Earth-observing satellites

Groundtrack

Live site

Landsat 8 (LDCM) model animation: NASA/Goddard Space Flight Center Conceptual Image Lab. Public domain. NASA SVS · Wikimedia Commons

Related examples

Make your own in the scroll image sequence generator or the scroll video scrub generator: set it up, watch it run, copy the code.

MIT licensed, zero runtime dependencies. Every API is a separate entry point, so you only ship what you import. Full API reference →