← Scroll

Scroll Frame Sequence

The section pins and a sequence of frames plays as you scroll, so a product turns round on a turntable under the reader’s thumb. Scroll back and it turns the other way. Point it at numbered image files and it preloads them and draws them on a canvas.

Scroll inside ↓

How to use it

  1. Paste the snippet just before </head>.
  2. Export your frames as numbered images (e.g. shoe-001.webp to shoe-060.webp).
  3. Add an empty <div data-scroll-frame-sequence> with data-src (the file name, with {n} for the number), data-count and, if the numbers have leading zeros, data-pad. Or put the frames inside it as images instead.

Options

Add any of these attributes to the same element.

data-srcThe frames’ address, with {n} where the number goes.
data-countHow many frames there are.
data-padPad the numbers with zeros to this many digits, e.g. 3 for 001. Default none.
data-startThe first frame’s number. Default 1.
data-lengthHow many screen heights of scrolling the frames take while pinned. Default 1.5; 0 plays them as it passes, without pinning.
data-turnsPlay the sequence this many times over the scroll. Default 1.
data-smoothHow quickly it catches up with the scroll, from 0 to 1. Default 0.25; 1 jumps straight to each frame.

Give the element a height when it isn’t pinned; while pinned it takes the height of the view. 40–120 frames of a few dozen KB each is the sweet spot. The demo’s frames are inline SVGs. With reduced motion it shows the first frame. Works inside scrolling boxes too.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Scroll Frame Sequence: a sequence of frames plays as you scroll, so a
    // product turns on a turntable (or anything else filmed frame by frame)
    // under the reader's thumb. The section pins while the frames play.
    // Usage: <div data-scroll-frame-sequence data-src="/frames/shoe-{n}.webp"
    //             data-count="60" data-pad="3"></div>
    //   ({n} becomes 001, 002 … 060), or put the frames inside it:
    //        <div data-scroll-frame-sequence>
    //          <img src="frame-1.jpg" alt=""><img src="frame-2.jpg" alt="">…
    //        </div>
    // Optional: data-start (first number, default 1), data-length (how many
    // screen heights of scrolling the frames take while pinned, default 1.5;
    // 0 plays them as the section passes without pinning), data-turns (play
    // the sequence this many times over the scroll, default 1), data-smooth
    // (0 to 1: how quickly it catches up with the scroll, default 0.25; 1 to
    // jump straight to each frame).
    // Works inside scrolling boxes too: it follows the nearest scrolling parent.
    var DEFAULTS = { start: 1, length: 1.5, turns: 1, smooth: 0.25 };

    var css =
      '.sfs{position:relative}' +
      '.sfs-pin{position:sticky;top:0;display:flex;align-items:center;justify-content:center;overflow:hidden}' +
      '.sfs-frames{position:relative;display:grid;width:100%;height:100%;place-items:center}' +
      '.sfs-frames>*{grid-area:1/1;max-width:100%;max-height:100%;visibility:hidden}' +
      '.sfs-frames>.sfs-on{visibility:visible}' +
      '.sfs-frames>canvas{width:100%;height:100%;visibility:visible}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function num(v, fallback) { return v !== undefined && v !== '' && !isNaN(+v) ? +v : fallback; }

    function scroller(el) {
      for (var p = el.parentElement; p && p !== document.body; p = p.parentElement) {
        var o = getComputedStyle(p).overflowY;
        if (o === 'auto' || o === 'scroll') return p;
      }
      return null;
    }

    function setup(el) {
      var d = el.dataset;
      var length = Math.max(0, num(d.length, DEFAULTS.length));
      var turns = Math.max(1, num(d.turns, DEFAULTS.turns));
      var smooth = Math.min(1, Math.max(0.01, num(d.smooth, DEFAULTS.smooth)));
      var still = reduced();
      var box = scroller(el);

      var pin = document.createElement('div');
      pin.className = 'sfs-pin';
      var frames = document.createElement('div');
      frames.className = 'sfs-frames';
      var kids = Array.prototype.slice.call(el.children);
      kids.forEach(function (k) { frames.appendChild(k); });
      pin.appendChild(frames);
      el.appendChild(pin);
      el.classList.add('sfs');

      // Frames from data-src are drawn on a canvas; frames inside are shown
      // one at a time.
      var count, imgs = null, canvas = null, ctx = null;
      if (d.src && d.count) {
        count = Math.max(1, Math.round(+d.count));
        var first = num(d.start, DEFAULTS.start), pad = num(d.pad, 0);
        imgs = [];
        for (var i = 0; i < count; i++) {
          var n = String(first + i);
          while (n.length < pad) n = '0' + n;
          var img = new Image();
          img.decoding = 'async';
          img.onload = draw;
          img.src = d.src.replace('{n}', n);
          imgs.push(img);
        }
        canvas = document.createElement('canvas');
        canvas.setAttribute('aria-hidden', 'true');
        frames.appendChild(canvas);
        ctx = canvas.getContext('2d');
      } else {
        count = kids.length;
        if (!count) return;
      }

      var viewH = 0, shown = -1, pos = 0, target = 0, raf = 0, queued = false;

      function measure() {
        viewH = box ? box.clientHeight : window.innerHeight;
        var pinned = length > 0 && !still;
        if (pinned) {
          pin.style.position = '';
          pin.style.height = viewH + 'px';
          el.style.height = viewH * (1 + length) + 'px';
        } else {
          // Not pinned: the frames fill the element as you've sized it.
          pin.style.position = 'static';
          pin.style.height = el.clientHeight > 40 ? el.clientHeight + 'px' : Math.round(viewH * 0.7) + 'px';
        }
        if (canvas) {
          var dpr = Math.min(2, window.devicePixelRatio || 1);
          canvas.width = Math.round(frames.clientWidth * dpr);
          canvas.height = Math.round(frames.clientHeight * dpr);
          ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
          shown = -1;
          draw();
        }
      }

      // Closest loaded frame to i, so a slow connection still shows something.
      function loaded(i) {
        for (var k = 0; k < count; k++) {
          var a = imgs[(i + k) % count], b = imgs[(i - k + count) % count];
          if (b.complete && b.naturalWidth) return b;
          if (a.complete && a.naturalWidth) return a;
        }
        return null;
      }

      function draw() {
        var i = Math.round(pos * (count - 1) * turns) % count;
        if (i === shown && !imgs) return;
        if (imgs) {
          var img = loaded(i);
          if (!img) return;
          var w = frames.clientWidth, h = frames.clientHeight;
          var s = Math.min(w / img.naturalWidth, h / img.naturalHeight);
          var dw = img.naturalWidth * s, dh = img.naturalHeight * s;
          ctx.clearRect(0, 0, w, h);
          ctx.drawImage(img, (w - dw) / 2, (h - dh) / 2, dw, dh);
        } else {
          if (shown >= 0 && kids[shown]) kids[shown].classList.remove('sfs-on');
          kids[i].classList.add('sfs-on');
        }
        shown = i;
      }

      function progress() {
        var r = el.getBoundingClientRect();
        var top = 0;
        if (box) top = box.getBoundingClientRect().top + box.clientTop;
        var p = length > 0
          ? (top - r.top) / (viewH * length)
          : (top + viewH - r.top) / (viewH + r.height);
        return Math.max(0, Math.min(1, p));
      }

      function tick() {
        raf = 0;
        pos += (target - pos) * smooth;
        if (Math.abs(target - pos) < 0.0005) pos = target;
        draw();
        if (pos !== target) raf = requestAnimationFrame(tick);
      }

      function update() {
        queued = false;
        target = progress();
        if (!raf) raf = requestAnimationFrame(tick);
      }

      function onScroll() {
        if (queued) return;
        queued = true;
        requestAnimationFrame(update);
      }

      measure();
      if (still) { draw(); return; }
      pos = target = progress();
      draw();
      (box || window).addEventListener('scroll', onScroll, { passive: true });
      window.addEventListener('resize', function () { measure(); onScroll(); });
      if (window.ResizeObserver) new ResizeObserver(function () { measure(); onScroll(); }).observe(frames);
    }

    function init() {
      document.querySelectorAll('[data-scroll-frame-sequence]').forEach(function (el) {
        if (el._sfs) return;
        el._sfs = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-scroll-frame-sequence
     data-src="/frames/shoe-{n}.webp" data-count="60" data-pad="3"></div>

<!-- or with the frames inside: -->
<div data-scroll-frame-sequence>
  <img src="turn-1.jpg" alt="">
  <img src="turn-2.jpg" alt="">
  …
</div>