← Cards & images

Polaroid Scatter

Photos in white instant-print frames drop onto the page one after another, landing at odd angles and sliding to a stop like prints tossed on a table. Tap one to pick it up for a closer look.

Northern lights
The lake
Dusk
Sunset
Night out
Seaside
Neon

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-polaroid-scatter to a container of photos (each a <figure> with an image and an optional caption). Give the container a height and the photos a width.

Options

Add any of these attributes to the same element.

data-angleThe most a print tilts, either way. Default 14°.
data-staggerTime between drops. Default 220 ms.
data-loopGather the prints up and drop them again somewhere new.
data-holdHow long the prints rest before looping. Default 2600 ms.
data-frame="false"Leave out the white frame, for photos you’ve styled yourself.

The prints spread over the whole container without going past its edges, in a new arrangement every time. Each one is a toggle button: tap, click or press Enter to lift it into the middle and straighten it. From code, animacious.polaroidScatter(element) drops them again.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Polaroid Scatter: photos drop onto the container one by one and land at
    // odd angles, sliding to a stop like prints tossed on a table. Tap a print
    // to pick it up and straighten it; tap again to put it back down.
    // Usage:
    //   <div data-polaroid-scatter style="height: 400px">
    //     <figure><img src="beach.jpg" alt="Beach"><figcaption>Beach</figcaption></figure>
    //     <figure>…</figure>
    //   </div>
    // Each child is a print; give the prints a width. The script adds the white
    // frame (unless data-frame="false") and scatters them over the container.
    // Optional: data-angle (most a print tilts, degrees, default 14),
    // data-stagger (ms between drops, default 220), data-loop (gather them up
    // and drop them again somewhere new), data-hold (ms the prints rest before
    // looping, default 2600).
    // Script use: animacious.polaroidScatter(element) drops them again.
    var DEFAULTS = { angle: 14, stagger: 220, hold: 2600 };
    var REST = '0 3px 8px rgba(15,15,30,.22)';
    var AIR = '0 34px 40px rgba(15,15,30,.12)';

    var css =
      '[data-polaroid-scatter]{position:relative}' +
      '[data-polaroid-scatter]>*{position:absolute;left:0;top:0;margin:0;opacity:0;cursor:pointer;-webkit-tap-highlight-color:transparent}' +
      '.plr-frame{padding:8px 8px 0;background:#fff;border-radius:3px;box-shadow:' + REST + '}' +
      '.plr-frame>img,.plr-frame>svg{display:block;width:100%;aspect-ratio:1;object-fit:cover}' +
      '.plr-frame>figcaption{padding:7px 2px 9px;font:600 14px/1.2 "Segoe Print","Bradley Hand","Comic Sans MS",cursive;color:#3f3f46;text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}' +
      '.plr-frame:not(:has(figcaption)){padding-bottom:30px}' +
      '[data-polaroid-scatter]>:focus-visible{outline:3px solid #a855f7;outline-offset:3px}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    var rnd = function (a, b) { return a + Math.random() * (b - a); };

    function setup(el) {
      var d = el.dataset;
      var prints = Array.from(el.children);
      var n = prints.length;
      if (!n) return;
      var angle = d.angle !== undefined ? +d.angle : DEFAULTS.angle;
      var stagger = d.stagger !== undefined ? +d.stagger : DEFAULTS.stagger;
      var hold = +d.hold || DEFAULTS.hold;
      var inThumb = !!el.closest('[aria-hidden="true"]');
      var spots = [], lifted = null, z = 1, timer = null, playing = false;

      prints.forEach(function (p) {
        if (d.frame !== 'false') p.classList.add('plr-frame');
        if (!inThumb) {
          p.tabIndex = 0;
          p.setAttribute('role', 'button');
          p.setAttribute('aria-pressed', 'false');
        }
      });

      // A fresh scatter: a jittered grid of spots in a random order, each with
      // a tilt. Stored as fractions so a resize keeps the same arrangement.
      function shuffle() {
        var W = el.clientWidth || 1, H = el.clientHeight || 1;
        var w = prints[0].offsetWidth || 120, h = prints[0].offsetHeight || 150;
        var cols = Math.max(1, Math.min(n, Math.round(Math.sqrt(n * (W / w) / Math.max(0.5, H / h)))));
        var rows = Math.ceil(n / cols);
        var order = prints.map(function (_, i) { return i; }).sort(function () { return Math.random() - 0.5; });
        spots = order.map(function (cell) {
          var c = cell % cols, r = Math.floor(cell / cols);
          var inRow = r === rows - 1 ? n - cols * (rows - 1) : cols;   // centre a short last row
          var shift = (cols - inRow) / 2;
          return {
            fx: cols === 1 ? 0.5 : Math.max(0, Math.min(1, (c + shift + rnd(-0.22, 0.22)) / (cols - 1))),
            fy: rows === 1 ? 0.5 : Math.max(0, Math.min(1, (r + rnd(-0.18, 0.18)) / (rows - 1))),
            rot: rnd(-angle, angle)
          };
        });
      }

      // Where a print's top-left goes for its spot, kept inside the container.
      function place(i) {
        var p = prints[i], s = spots[i];
        var W = el.clientWidth, H = el.clientHeight, w = p.offsetWidth, h = p.offsetHeight;
        var m = 10;
        return {
          x: m + s.fx * Math.max(0, W - w - 2 * m) + (W - w - 2 * m < 0 ? (W - w) / 2 - m : 0),
          y: m + s.fy * Math.max(0, H - h - 2 * m) + (H - h - 2 * m < 0 ? (H - h) / 2 - m : 0),
          rotate: s.rot
        };
      }

      function settle() {
        prints.forEach(function (p, i) {
          if (p === lifted) return;
          var to = place(i);
          anime.utils.set(p, { x: to.x, y: to.y, rotate: to.rotate });
        });
        if (lifted) anime.utils.set(lifted, liftPose());
      }

      function drop() {
        playing = true;
        lifted = null;
        shuffle();
        var done = 0;
        prints.forEach(function (p, i) {
          var to = place(i);
          p.style.zIndex = ++z;
          if (!inThumb) p.setAttribute('aria-pressed', 'false');
          if (reduced()) {
            anime.utils.set(p, { x: to.x, y: to.y, rotate: to.rotate, scale: 1, opacity: 1 });
            return;
          }
          // Falls towards the table (growing smaller as it drops), then slides to a stop.
          var dx = rnd(-30, 30), dy = rnd(-24, 24), spin = rnd(-25, 25);
          anime.utils.set(p, { x: to.x - dx, y: to.y - dy - 30, rotate: to.rotate - spin, scale: 1.7, opacity: 0, boxShadow: AIR });
          var tl = anime.createTimeline({ delay: i * stagger, onComplete: function () { if (++done === n) rest(); } });
          tl.add(p, { scale: 1, opacity: { to: 1, duration: 160 }, y: to.y - dy * 0.3, x: to.x - dx * 0.3, rotate: to.rotate - spin * 0.3, boxShadow: REST, duration: 420, ease: 'in(2)' })
            .add(p, { x: to.x, y: to.y, rotate: to.rotate, duration: 520, ease: 'out(3)' });
        });
        if (reduced()) rest();
      }

      function rest() {
        playing = false;
        if (d.loop === undefined || reduced()) return;
        clearTimeout(timer);
        timer = setTimeout(gather, hold);
      }

      // Lift the prints off the table, last dropped first, then drop again.
      function gather() {
        if (lifted || !visible || el.closest('[hidden]')) { timer = setTimeout(gather, 1200); return; }
        playing = true;
        var left = n;
        prints.slice().reverse().forEach(function (p, k) {
          anime.animate(p, {
            scale: 1.4, opacity: 0, y: '-=20', rotate: '+=' + rnd(-12, 12).toFixed(1), boxShadow: AIR,
            duration: 380, delay: k * 70, ease: 'in(2)',
            onComplete: function () { if (--left === 0) setTimeout(drop, 250); }
          });
        });
      }

      function liftPose() {
        var w = lifted.offsetWidth, h = lifted.offsetHeight;
        return { x: (el.clientWidth - w) / 2, y: (el.clientHeight - h) / 2, rotate: 0, scale: Math.min(1.25, (el.clientHeight - 16) / h) };
      }

      // Pick a print up into the middle, or put it back.
      function toggle(p) {
        if (playing) return;
        clearTimeout(timer);
        var ms = reduced() ? 0 : 450;
        if (lifted && lifted !== p) putBack(lifted, ms);
        if (lifted === p) { putBack(p, ms); lifted = null; rest(); return; }
        lifted = p;
        p.style.zIndex = ++z;
        p.setAttribute('aria-pressed', 'true');
        var to = liftPose();
        to.boxShadow = '0 24px 44px rgba(15,15,30,.28)';
        to.duration = ms;
        to.ease = 'out(3)';
        anime.animate(p, to);
      }
      function putBack(p, ms) {
        var to = place(prints.indexOf(p));
        p.setAttribute('aria-pressed', 'false');
        anime.animate(p, { x: to.x, y: to.y, rotate: to.rotate, scale: 1, boxShadow: REST, duration: ms, ease: 'out(3)' });
      }

      if (!inThumb) {
        prints.forEach(function (p) {
          p.addEventListener('click', function () { toggle(p); });
          p.addEventListener('keydown', function (e) {
            if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(p); }
            if (e.key === 'Escape' && lifted === p) toggle(p);
          });
        });
      }

      el._plr = function () { clearTimeout(timer); anime.utils.remove(prints); drop(); };

      var visible = false, started = false;
      shuffle();
      new IntersectionObserver(function (entries) {
        visible = entries[0].isIntersecting;
        if (visible && !started) { started = true; drop(); }
      }, { threshold: 0.2 }).observe(el);
      if (window.ResizeObserver) new ResizeObserver(function () { if (started && !playing) settle(); }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-polaroid-scatter]').forEach(function (el) {
        if (el._plr) return;
        el._plr = true;
        setup(el);
      });
    }

    window.animacious = window.animacious || {};
    window.animacious.polaroidScatter = function (el) { if (el && typeof el._plr === 'function') el._plr(); };
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-polaroid-scatter style="height: 400px">
  <figure style="width: 150px">
    <img src="beach.jpg" alt="The beach">
    <figcaption>Beach day</figcaption>
  </figure>
  <figure style="width: 150px">…</figure>
  <figure style="width: 150px">…</figure>
</div>