← Cards & images

Photo Booth Strip

The flash goes off once for each picture, then a strip of photos prints out of the slot a frame at a time. Once it’s all out it drops down with a bounce and lands at a slight tilt, just like a real booth.

Tap the booth to print another

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-photo-booth-strip to a box holding your photos, top to bottom. They’re cropped to 4 : 3.
  3. Set the strip’s width with --pbs-w if you want it wider than 120 px.

Options

Add any of these attributes to the same element.

data-captionText along the bottom of the strip.
data-flash="false"No flashes before it prints.
data-stepTime to print each frame. Default 550 ms.
data-dropHow far the strip falls, in px. Default 26.
data-loopClear it away and print another.
data-holdHow long the strip stays before the next. Default 2600 ms.
data-colorColour of the machine. Default #2a2235.

It prints when it scrolls into view, and a click or tap prints it again. The bottom of the strip comes out first, as it would from a real slot. From code: animacious.photoBooth(element). With reduced motion the strip is simply shown.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Photo Booth Strip: the flash goes off once for each picture, then a
    // strip of photos prints out of a slot a frame at a time, and once it's
    // all out it drops down with a little bounce and a tilt, like a real booth.
    // Usage: <div data-photo-booth-strip data-caption="Best day ever">
    //          <img src="1.jpg" alt=""> <img src="2.jpg" alt=""> <img src="3.jpg" alt="">
    //        </div>
    // The children are the photos, top to bottom; they're cropped to 4:3.
    // Set the strip's width with --pbs-w (default 120px).
    // Optional: data-caption (text along the bottom of the strip), data-flash="false"
    // (no flashes), data-step (ms to print each frame, default 550), data-drop (px
    // the strip falls, default 26), data-loop, data-hold (ms before printing the
    // next, default 2600), data-color (the machine's colour, default #2a2235).
    // A click or tap prints it again. Script use: animacious.photoBooth(el).
    var DEFAULTS = { step: 550, drop: 26, hold: 2600 };

    var css =
      '.pbs{position:relative;display:grid;justify-items:center;-webkit-tap-highlight-color:transparent}' +
      '.pbs-slot{position:relative;z-index:2;width:calc(var(--pbs-w,120px) + 36px);height:22px;border-radius:7px 7px 5px 5px;background:linear-gradient(#4a3f5c,var(--pbs-c,#2a2235) 60%);box-shadow:0 3px 8px rgba(20,10,40,.3),inset 0 1px rgba(255,255,255,.18)}' +
      '.pbs-slot::after{content:"";position:absolute;left:12px;right:12px;bottom:5px;height:4px;border-radius:3px;background:#0b0810;box-shadow:inset 0 1px 2px #000}' +
      '.pbs-win{position:relative;z-index:1;width:var(--pbs-w,120px);margin-top:-7px;-webkit-clip-path:inset(0 -80px -80px -80px);clip-path:inset(0 -80px -80px -80px)}' +
      '.pbs-strip{display:grid;gap:calc(var(--pbs-w,120px) * .06);padding:calc(var(--pbs-w,120px) * .08) calc(var(--pbs-w,120px) * .08) calc(var(--pbs-w,120px) * .05);background:#fdfcf8;box-shadow:0 6px 16px rgba(20,10,40,.22),0 1px 2px rgba(20,10,40,.2);transform-origin:50% 0;will-change:transform}' +
      '.pbs-strip>.pbs-ph{display:block;width:100%;aspect-ratio:4/3;object-fit:cover;overflow:hidden;background:#ddd}' +
      '.pbs-ph>svg,.pbs-ph>img{display:block;width:100%;height:100%;object-fit:cover}' +
      '.pbs-cap{font:600 calc(var(--pbs-w,120px) * .085)/1.3 "Segoe Print","Bradley Hand","Comic Sans MS",cursive;color:#5b4a6b;text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}' +
      '.pbs-flash{position:absolute;inset:-12px;z-index:3;border-radius:14px;background:radial-gradient(#fff 30%,rgba(255,255,255,.6) 70%,rgba(255,255,255,0));opacity:0;pointer-events:none}';
    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 photos = Array.prototype.slice.call(el.children);
      if (!photos.length) return;
      el.classList.add('pbs');
      if (d.color) el.style.setProperty('--pbs-c', d.color);
      var slot = document.createElement('div');
      slot.className = 'pbs-slot';
      slot.setAttribute('aria-hidden', 'true');
      var win = document.createElement('div');
      win.className = 'pbs-win';
      var strip = document.createElement('div');
      strip.className = 'pbs-strip';
      photos.forEach(function (p) {
        var frame = p;
        if (p.tagName !== 'IMG') {
          frame = document.createElement('span');
          frame.appendChild(p);
        }
        frame.classList.add('pbs-ph');
        strip.appendChild(frame);
      });
      if (d.caption) {
        var cap = document.createElement('div');
        cap.className = 'pbs-cap';
        cap.textContent = d.caption;
        strip.appendChild(cap);
      }
      var flash = document.createElement('div');
      flash.className = 'pbs-flash';
      flash.setAttribute('aria-hidden', 'true');
      win.appendChild(strip);
      el.appendChild(slot);
      el.appendChild(win);
      el.appendChild(flash);

      var s = el._pbsS = { strip: strip, win: win, flash: flash, frames: strip.querySelectorAll('.pbs-ph'), tl: null, timer: 0, visible: false, done: false };
      function size() {
        var drop = d.drop !== undefined ? +d.drop : DEFAULTS.drop;
        win.style.height = strip.offsetHeight + drop + 10 + 'px';
      }
      size();
      new ResizeObserver(size).observe(strip);
      if (reduced()) return;

      anime.utils.set(strip, { y: -strip.offsetHeight - 4 });
      el.addEventListener('click', function () { play(el); });
      var played = false;
      new IntersectionObserver(function (en) {
        s.visible = en[0].isIntersecting;
        if (s.visible && !played) { played = true; play(el); }
        if (s.visible && s.waiting) { s.waiting = false; play(el); }
      }, { threshold: 0.3 }).observe(el);
    }

    function play(el) {
      var s = el._pbsS, d = el.dataset;
      if (!s || reduced()) return;
      if (s.tl) s.tl.pause();
      clearTimeout(s.timer);
      var strip = s.strip, H = strip.offsetHeight;
      var n = s.frames.length;
      var step = +d.step || DEFAULTS.step;
      var drop = d.drop !== undefined ? +d.drop : DEFAULTS.drop;
      var tl = s.tl = anime.createTimeline();
      var t = 0;

      // Clear away the last strip.
      if (s.done) {
        tl.add(strip, { y: '+=30', opacity: 0, duration: 400, ease: 'in(2)' }, 0);
        t = 450;
      }
      tl.set(strip, { y: -H - 4, rotate: 0, opacity: 1 }, t);

      // One flash per photo.
      if (d.flash !== 'false') {
        for (var i = 0; i < n; i++) {
          tl.add(s.flash, { opacity: [0, 0.95, 0], duration: 260, ease: 'out(2)' }, t + 150 + i * 340);
        }
        t += 150 + n * 340 + 250;
      }

      // Print a frame at a time, with a pause as each one comes out. The
      // bottom of the strip leads, so the last photo shows first.
      for (var k = 0; k < n; k++) {
        var j = n - 1 - k, f = s.frames[j], prev = s.frames[j - 1];
        // The gap above frame j: how much of the strip is out after this step.
        var above = j === 0 ? 0 : (prev.offsetTop + prev.offsetHeight + f.offsetTop) / 2;
        tl.add(strip, { y: -above, duration: step, ease: 'inOut(2)' }, t);
        t += step + 160;
      }
      // Then let go: it drops with a tilt and a bounce.
      var tilt = (Math.random() < 0.5 ? -1 : 1) * (3 + Math.random() * 3);
      tl.add(strip, { y: drop, rotate: tilt, duration: 750, ease: 'outBounce' }, t);
      tl.call(function () {
        s.done = true;
        if (d.loop !== undefined) {
          s.timer = setTimeout(function () {
            if (s.visible) play(el); else s.waiting = true;
          }, d.hold !== undefined ? +d.hold : DEFAULTS.hold);
        }
      }, t + 750);
    }

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

    window.animacious = window.animacious || {};
    window.animacious.photoBooth = play;
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-photo-booth-strip data-caption="Best day ever" style="--pbs-w: 140px">
  <img src="snap-1.jpg" alt="">
  <img src="snap-2.jpg" alt="">
  <img src="snap-3.jpg" alt="">
  <img src="snap-4.jpg" alt="">
</div>