← Scroll

Grid Explode on Scroll

A grid of images pins to the screen and flies apart in 3D as you scroll: each tile shoots outwards from the centre, tumbling, some towards you and some away, to reveal what’s behind. Scroll back and the pieces fly home and lock into place.

Scroll inside ↓

Break freeScroll back to put it together.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-grid-explode-on-scroll to a section whose first child is your grid of images.
  3. Optionally add an element marked data-behind: it sits behind the grid and comes forward as the tiles fly away.

Options

Add any of these attributes to the same element.

data-lengthHow many screen heights of scrolling the explosion takes while pinned. Default 1.2.
data-distanceHow far the tiles fly, as a multiple of the grid’s size. Default 1.1.
data-spinThe most a tile tumbles, in degrees. Default 70.
data-depthThe most a tile flies towards or away from you, in px. Default 500.
data-reverseStart blown apart and assemble the grid as you scroll.

It pins with position: sticky, so nothing around it can have overflow: hidden. Each tile always flies the same way. With reduced motion the grid simply shows as it is. 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 () {
    // Grid Explode on Scroll: a grid of images pins to the screen and, as you
    // scroll, flies apart in 3D: each tile shoots outwards from the centre,
    // tumbling, some towards you and some away, and fades as it goes.
    // Scroll back and the pieces fly home and lock into place.
    // Usage: <section data-grid-explode-on-scroll>
    //          <div class="my-grid">…images…</div>   (the first child is the grid)
    //          <h2 data-behind>Hello</h2>              (optional: revealed behind it)
    //        </section>
    // Optional: data-length (how many screen heights of scrolling the
    // explosion takes while pinned, default 1.2), data-distance (how far the
    // tiles fly, as a multiple of the grid's size, default 1.1), data-spin
    // (most degrees a tile tumbles, default 70), data-depth (most px a tile
    // flies towards or away from you, default 500), data-reverse (start blown
    // apart and assemble as you scroll).
    // Works inside scrolling boxes too: it follows the nearest scrolling parent.
    var DEFAULTS = { length: 1.2, distance: 1.1, spin: 70, depth: 500 };

    var css =
      '.gex{position:relative}' +
      '.gex-pin{position:sticky;top:0;display:grid;place-items:center;overflow:hidden;perspective:900px}' +
      '.gex-pin>*{grid-area:1/1}' +
      '.gex-grid{position:relative;z-index:1;transform-style:preserve-3d}' +
      '.gex-tile{backface-visibility:hidden;will-change:transform,opacity}';
    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 ease(t) { return t * t * (3 - 2 * t); }

    // A repeatable "random" number from -1 to 1, so each tile always flies the same way.
    function rand(i, k) {
      var x = Math.sin(i * 127.1 + k * 311.7) * 43758.5453;
      return (x - Math.floor(x)) * 2 - 1;
    }

    function setup(el) {
      var d = el.dataset;
      var length = Math.max(0.2, num(d.length, DEFAULTS.length));
      var distance = num(d.distance, DEFAULTS.distance);
      var spin = num(d.spin, DEFAULTS.spin);
      var depth = num(d.depth, DEFAULTS.depth);
      var reverse = d.reverse !== undefined && d.reverse !== 'false';
      var grid = el.firstElementChild;
      if (!grid) return;
      var behind = el.querySelector(':scope > [data-behind]');
      var tiles = Array.prototype.slice.call(grid.children);
      var box = scroller(el);

      var pin = document.createElement('div');
      pin.className = 'gex-pin';
      if (behind) pin.appendChild(behind);
      pin.appendChild(grid);
      el.appendChild(pin);
      el.classList.add('gex');
      grid.classList.add('gex-grid');
      tiles.forEach(function (t) { t.classList.add('gex-tile'); });

      var viewH = 0, queued = false, last = -1, flights = [];

      function measure() {
        viewH = box ? box.clientHeight : window.innerHeight;
        pin.style.height = viewH + 'px';
        el.style.height = viewH * (1 + length) + 'px';
        tiles.forEach(function (t) { t.style.transform = ''; });
        var gr = grid.getBoundingClientRect();
        var cx = gr.left + gr.width / 2, cy = gr.top + gr.height / 2;
        var size = Math.max(gr.width, gr.height) / 2 || 1;
        // Each tile flies out along the line from the grid's centre through it, with a little wobble.
        flights = tiles.map(function (t, i) {
          var r = t.getBoundingClientRect();
          var ox = (r.left + r.width / 2 - cx) / size, oy = (r.top + r.height / 2 - cy) / size;
          var a = Math.atan2(oy || rand(i, 9) * 0.1, ox || rand(i, 8) * 0.1) + rand(i, 1) * 0.35;
          var reach = (0.7 + 0.6 * Math.min(1, Math.sqrt(ox * ox + oy * oy))) * size * distance * (0.85 + 0.3 * Math.abs(rand(i, 2)));
          return {
            x: Math.cos(a) * reach,
            y: Math.sin(a) * reach,
            z: rand(i, 3) * depth,
            rx: rand(i, 4) * spin,
            ry: rand(i, 5) * spin,
            rz: rand(i, 6) * spin * 0.5,
            lag: Math.abs(rand(i, 7)) * 0.18,
          };
        });
        last = -1;
      }

      function update() {
        queued = false;
        var top = box ? box.getBoundingClientRect().top + box.clientTop : 0;
        var y = el.getBoundingClientRect().top - top;
        var p = Math.max(0, Math.min(1, -y / (viewH * length)));
        if (reverse) p = 1 - p;
        if (Math.abs(p - last) < 0.0005) return;
        last = p;
        for (var i = 0; i < tiles.length; i++) {
          var f = flights[i];
          var t = ease(Math.max(0, Math.min(1, (p - f.lag) / (1 - f.lag))));
          tiles[i].style.transform = t <= 0 ? '' :
            'translate3d(' + (f.x * t).toFixed(1) + 'px,' + (f.y * t).toFixed(1) + 'px,' + (f.z * t).toFixed(1) + 'px)' +
            ' rotateX(' + (f.rx * t).toFixed(1) + 'deg) rotateY(' + (f.ry * t).toFixed(1) + 'deg) rotateZ(' + (f.rz * t).toFixed(1) + 'deg)';
          tiles[i].style.opacity = Math.max(0, Math.min(1, (1 - t) * 2.2)).toFixed(3);
        }
        if (behind) {
          var b = ease(Math.max(0, Math.min(1, (p - 0.25) / 0.6)));
          behind.style.opacity = b.toFixed(3);
          behind.style.transform = 'scale(' + (0.82 + 0.18 * b).toFixed(4) + ')';
        }
      }

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

      measure();
      update();
      (box || window).addEventListener('scroll', onScroll, { passive: true });
      window.addEventListener('resize', function () { measure(); update(); });
      if (window.ResizeObserver) {
        var ro = new ResizeObserver(function () { measure(); update(); });
        ro.observe(grid);
        if (box) ro.observe(box);
      }
    }

    function init() {
      if (reduced()) return;   // no motion: the grid simply shows as it is
      document.querySelectorAll('[data-grid-explode-on-scroll]').forEach(function (el) {
        if (el._gex) return;
        el._gex = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section data-grid-explode-on-scroll>
  <div class="gallery">
    <img src="1.jpg" alt="…">
    <img src="2.jpg" alt="…">
    …
  </div>
  <h2 data-behind>Break free</h2>
</section>