← Cards & images

Film Strip

Pictures run past on a strip of film with sprocket holes along both edges. Let it roll smoothly, or switch to projector mode: each frame stops in the middle, then the next is pulled in with a blink of the shutter while the light flickers.

Night Drive
Bloom
Tides
Neon

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-film-strip to a box holding your pictures. They become the frames and repeat for as long as it runs.
  3. Set the frame size with --fst-w and --fst-h on the box if you want it bigger or smaller.

Options

Add any of these attributes to the same element.

data-moderoll moves smoothly; step stops on each frame like a projector. Default roll.
data-speedSpeed when rolling, in px a second. Default 45.
data-holdHow long each frame rests in step mode. Default 900 ms.
data-directionleft, right, up or down. Default left.
data-flickerFlicker the light over the frames. On in step mode; add it to a rolling strip too, or set false.
data-colorColour of the film. Default #141414.
--fst-w / --fst-hCSS variables for the frame size. Default 128 × 92 px.

The frames are copied as many times as it takes to fill the box, so the strip never shows a gap. It only runs while on screen, and with reduced motion it stands still.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Film Strip: pictures run past on a strip of film with sprocket holes,
    // either rolling smoothly or jumping a frame at a time like film through
    // a projector, with the shutter blinking and the light flickering.
    // Usage: <div data-film-strip><img src="1.jpg" alt="…"> <img src="2.jpg" alt="…"> …</div>
    // The children are the frames; they repeat for as long as it runs. Set the
    // frame size with the CSS variables --fst-w and --fst-h (default 128 × 92 px).
    // Optional: data-mode ("roll", default, or "step": a frame at a time),
    // data-speed (px a second when rolling, default 45), data-hold (ms each
    // frame rests in step mode, default 900), data-direction ("left",
    // default, "right", "up" or "down"), data-flicker (flicker the light;
    // on by default in step mode, "false" to turn off), data-color (colour of
    // the film, default #141414).
    var DEFAULTS = { speed: 45, hold: 900 };

    var css =
      '.fst{position:relative;overflow:hidden;--fst-c:#141414}' +
      '.fst-track{display:flex;width:max-content;will-change:transform}' +
      '.fst-v .fst-track{flex-direction:column;width:auto;height:max-content}' +
      '.fst-frame{position:relative;flex:none;padding:calc(var(--fst-h,92px)*.2) 5px;background:var(--fst-c)}' +
      '.fst-v .fst-frame{padding:5px calc(var(--fst-w,128px)*.16)}' +
      '.fst-frame::before,.fst-frame::after{content:"";position:absolute;left:0;right:0;height:calc(var(--fst-h,92px)*.2);background:linear-gradient(90deg,#0000 22%,#f2ede4 22% 64%,#0000 64%) 0 50%/calc(var(--fst-w,128px)/6 + 10px/6) 38% repeat-x}' +
      '.fst-frame::before{top:0}.fst-frame::after{bottom:0}' +
      '.fst-v .fst-frame::before,.fst-v .fst-frame::after{top:0;bottom:0;left:auto;right:auto;width:calc(var(--fst-w,128px)*.16);height:auto;background:linear-gradient(#0000 22%,#f2ede4 22% 64%,#0000 64%) 50% 0/38% calc(var(--fst-h,92px)/4 + 10px/4) repeat-y}' +
      '.fst-v .fst-frame::before{left:0}.fst-v .fst-frame::after{right:0}' +
      '.fst-frame>img,.fst-frame>svg,.fst-frame>video{display:block}' +
      '.fst-frame>*{width:var(--fst-w,128px)!important;height:var(--fst-h,92px)!important;margin:0;border-radius:2px;object-fit:cover}' +
      '.fst-light{position:absolute;inset:0;pointer-events:none;background:radial-gradient(ellipse at 50% 50%,rgba(255,244,214,.18),rgba(0,0,0,.28) 85%)}' +
      '.fst-shutter{position:absolute;inset:0;pointer-events:none;background:#000;opacity:0}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    function setup(el) {
      var d = el.dataset;
      var items = Array.prototype.slice.call(el.children);
      if (!items.length) return;
      var dir = d.direction || 'left';
      var vertical = dir === 'up' || dir === 'down';
      el.classList.add('fst');
      if (vertical) el.classList.add('fst-v');
      if (d.color) el.style.setProperty('--fst-c', d.color);
      var track = document.createElement('div');
      track.className = 'fst-track';
      items.forEach(function (n) {
        var f = document.createElement('div');
        f.className = 'fst-frame';
        f.appendChild(n);
        track.appendChild(f);
      });
      el.appendChild(track);
      var step = d.mode === 'step';
      var light = null, shutter = null;
      if (d.flicker !== 'false' && (step || d.flicker !== undefined)) {
        light = document.createElement('div');
        light.className = 'fst-light';
        light.setAttribute('aria-hidden', 'true');
        el.appendChild(light);
      }
      if (step) {
        shutter = document.createElement('div');
        shutter.className = 'fst-shutter';
        shutter.setAttribute('aria-hidden', 'true');
        el.appendChild(shutter);
      }

      var s = el._fstS = {
        track: track, n: items.length, vertical: vertical, sign: dir === 'right' || dir === 'down' ? 1 : -1,
        step: step, light: light, shutter: shutter, pos: 0, set: 0, frame: 0, adjust: 0, raf: 0, last: 0,
        visible: false, rest: 0, moving: null
      };

      measure(el);
      new ResizeObserver(function () { measure(el); }).observe(el);
      if (reduced()) return;
      new IntersectionObserver(function (en) {
        s.visible = en[0].isIntersecting;
        if (s.visible) kick(el);
      }).observe(el);
    }

    // Clone the frames until the strip covers the box twice over, so it can wrap seamlessly.
    function measure(el) {
      var s = el._fstS;
      var frames = s.track.children;
      var first = frames[0], last = frames[s.n - 1];
      s.set = s.vertical
        ? last.offsetTop + last.offsetHeight - first.offsetTop
        : last.offsetLeft + last.offsetWidth - first.offsetLeft;
      s.frame = s.set / s.n;
      if (!s.set) return;
      var span = s.vertical ? el.clientHeight : el.clientWidth;
      var copies = Math.ceil((span + s.frame) / s.set) + 1;
      // In step mode a frame rests in the middle, as if in the projector's gate.
      s.adjust = s.step ? ((((span - s.frame) / 2) % s.frame) + s.frame) % s.frame - s.frame : 0;
      var have = frames.length / s.n - 1;
      for (var c = have; c < copies; c++) {
        for (var i = 0; i < s.n; i++) {
          var copy = frames[i].cloneNode(true);
          copy.setAttribute('aria-hidden', 'true');
          copy.querySelectorAll('[id]').forEach(function (m) { m.removeAttribute('id'); });
          s.track.appendChild(copy);
        }
      }
      place(el);
    }

    function place(el) {
      var s = el._fstS;
      // pos counts up; the strip shows it modulo one set, in the chosen direction.
      var p = ((s.pos % s.set) + s.set) % s.set;
      var off = (s.sign < 0 ? -p : p - s.set) + s.adjust;
      s.track.style.transform = s.vertical ? 'translate3d(0,' + off.toFixed(2) + 'px,0)' : 'translate3d(' + off.toFixed(2) + 'px,0,0)';
    }

    function kick(el) {
      var s = el._fstS;
      if (s.raf || !s.visible) return;
      s.last = performance.now();
      s.raf = requestAnimationFrame(function tick(now) {
        s.raf = 0;
        if (!s.visible || !s.set) return;
        var d = el.dataset, dt = Math.min(50, now - s.last);
        s.last = now;
        if (!s.step) {
          s.pos += (+d.speed || DEFAULTS.speed) * dt / 1000;
        } else if (!s.moving) {
          s.rest += dt;
          if (s.rest >= (+d.hold || DEFAULTS.hold)) {
            // Pull the next frame down fast, with the shutter closed.
            s.rest = 0;
            var from = Math.round(s.pos / s.frame) * s.frame;
            s.moving = { from: from, t: 0 };
          }
        }
        if (s.moving) {
          s.moving.t = Math.min(1, s.moving.t + dt / 260);
          var e = s.moving.t, k = e < 0.5 ? 4 * e * e * e : 1 - Math.pow(-2 * e + 2, 3) / 2;
          s.pos = s.moving.from + s.frame * k;
          s.shutter.style.opacity = (Math.sin(Math.PI * e) * 0.55).toFixed(3);
          if (e >= 1) s.moving = null;
        }
        if (s.light && Math.random() < 0.35) s.light.style.opacity = (0.75 + Math.random() * 0.25).toFixed(2);
        place(el);
        s.raf = requestAnimationFrame(tick);
      });
    }

    function init() {
      document.querySelectorAll('[data-film-strip]').forEach(function (el) {
        if (el._fst) return;
        el._fst = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-film-strip data-mode="step" style="--fst-w: 160px; --fst-h: 110px">
  <img src="1.jpg" alt="">
  <img src="2.jpg" alt="">
  <img src="3.jpg" alt="">
</div>