← Cards & images

Stack to Grid

A messy pile of photos deals itself out into a tidy grid, top photo first, each one gliding to its own cell. Click and they sweep back into the pile; click again and they spread out.

Click the photos to stack or spread them

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-stack-to-grid to a grid of photos. Lay the grid out with your own CSS: that’s where the photos end up.

Options

Add any of these attributes to the same element.

data-loopSpread and stack on a timer, until someone clicks.
data-intervalTime between changes when looping. Default 2800 ms.
data-durationTime for each photo to move. Default 750 ms.
data-staggerGap between photos. Default 60 ms.
data-angleThe most a photo tilts in the pile. Default 10°.
data-trigger="none"Clicking does nothing; drive it from code instead.

It starts as a pile and spreads out when it scrolls into view. The grid is a toggle button (Enter and Space work too). From code, animacious.stackToGrid(element, false) stacks it and true spreads it out.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Stack to Grid: the photos start as a messy pile in the middle, then deal
    // themselves out into their places in a tidy grid. Click (or tap) to sweep
    // them back into a pile, and again to spread them out.
    // Usage:
    //   <div data-stack-to-grid class="my-grid">
    //     <img src="one.jpg" alt="…"> <img src="two.jpg" alt="…"> …
    //   </div>
    // Lay the children out as a grid with your own CSS: that is where they
    // end up. The script only moves them there and back.
    // Optional: data-loop (spread and stack on a timer), data-interval (ms
    // between changes when looping, default 2800), data-duration (ms per photo,
    // default 750), data-stagger (ms between photos, default 60),
    // data-angle (most a photo tilts in the pile, degrees, default 10),
    // data-trigger="none" (clicks do nothing).
    // Script use: animacious.stackToGrid(element, true|false) spreads or stacks.
    var DEFAULTS = { interval: 2800, duration: 750, stagger: 60, angle: 10 };

    var css =
      '[data-stack-to-grid]{position:relative}' +
      '[data-stack-to-grid]>*{position:relative}' +
      '[data-stack-to-grid].stg-click{cursor:pointer}' +
      '[data-stack-to-grid]:focus-visible{outline:3px solid #a855f7;outline-offset:6px}';
    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.from(el.children);
      var n = items.length;
      if (!n) return;
      var duration = +d.duration || DEFAULTS.duration;
      var stagger = d.stagger !== undefined ? +d.stagger : DEFAULTS.stagger;
      var interval = +d.interval || DEFAULTS.interval;
      var angle = d.angle !== undefined ? +d.angle : DEFAULTS.angle;
      var inThumb = !!el.closest('[aria-hidden="true"]');
      var spread = false, timer = null, user = false;
      // Each photo's own mess in the pile, kept so the pile looks the same every time.
      var mess = items.map(function () {
        return { x: (Math.random() - 0.5) * 34, y: (Math.random() - 0.5) * 22, rotate: (Math.random() * 2 - 1) * angle };
      });
      items.forEach(function (it, i) { it.style.zIndex = i + 1; });

      // The offset that moves an item from its grid cell to the middle of the pile.
      function pile(i) {
        var it = items[i];
        var cx = el.clientWidth / 2, cy = el.clientHeight / 2;
        return {
          x: cx - (it.offsetLeft + it.offsetWidth / 2) + mess[i].x,
          y: cy - (it.offsetTop + it.offsetHeight / 2) + mess[i].y,
          rotate: mess[i].rotate
        };
      }

      function show(toGrid, animate) {
        spread = toGrid;
        el.classList.toggle('stg-stacked', !toGrid);
        if (!inThumb && el.classList.contains('stg-click')) el.setAttribute('aria-pressed', toGrid ? 'true' : 'false');
        items.forEach(function (it, i) {
          var to = toGrid ? { x: 0, y: 0, rotate: 0 } : pile(i);
          if (!animate || reduced()) { anime.utils.remove(it); return anime.utils.set(it, to); }
          // Dealt from the top of the pile out; gathered in reverse.
          var k = toGrid ? n - 1 - i : i;
          to.duration = duration;
          to.delay = k * stagger;
          to.ease = toGrid ? 'out(4)' : 'inOut(3)';
          to.scale = [{ to: toGrid ? 1.06 : 0.97, duration: duration * 0.4, ease: 'out(2)' }, { to: 1, duration: duration * 0.6, ease: 'out(3)' }];
          anime.animate(it, to);
        });
      }
      function toggle() { show(!spread, true); }
      el._stg = function (on) { user = true; clearInterval(timer); show(on === undefined ? !spread : !!on, true); };

      if (!inThumb && d.trigger !== 'none') {
        el.classList.add('stg-click');
        el.setAttribute('role', 'button');
        if (!el.hasAttribute('tabindex')) el.tabIndex = 0;
        if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', 'Spread out or stack the photos');
        el.addEventListener('click', function () { user = true; clearInterval(timer); toggle(); });
        el.addEventListener('keydown', function (e) {
          if (e.key !== 'Enter' && e.key !== ' ') return;
          e.preventDefault();
          user = true;
          clearInterval(timer);
          toggle();
        });
      }

      show(reduced(), false);
      if (window.ResizeObserver) new ResizeObserver(function () { if (!spread) show(false, false); }).observe(el);
      if (reduced()) return;

      var started = false;
      new IntersectionObserver(function (entries) {
        clearInterval(timer);
        timer = null;
        if (!entries[0].isIntersecting) return;
        if (!started) { started = true; setTimeout(function () { show(true, true); }, 300); }
        if (d.loop !== undefined && !user) {
          timer = setInterval(function () { if (!el.closest('[hidden]')) toggle(); }, interval);
        }
      }, { threshold: 0.2 }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-stack-to-grid]').forEach(function (el) {
        if (el._stg) return;
        el._stg = true;
        setup(el);
      });
    }

    window.animacious = window.animacious || {};
    window.animacious.stackToGrid = function (el, on) { if (el && typeof el._stg === 'function') el._stg(on); };
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-stack-to-grid style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px">
  <img src="one.jpg" alt="…">
  <img src="two.jpg" alt="…">
  <img src="three.jpg" alt="…">
  <img src="four.jpg" alt="…">
  <img src="five.jpg" alt="…">
  <img src="six.jpg" alt="…">
</div>