← Scroll

Zoom-out to Grid

One image fills the screen, then as you scroll the view pulls back: it shrinks into its place in a grid while the other images slide in around it, the far ones settling last. Scroll back up and it zooms in again.

Scroll inside ↓

Explore

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-zoom-out-to-grid to a section whose first child is your grid of images (any layout).
  3. Mark the image that starts full screen with data-hero. Anything inside it marked data-caption fades away as it shrinks.

Options

Add any of these attributes to the same element.

data-lengthHow many screen heights of scrolling the zoom takes while pinned. Default 1.2.
data-radiusThe hero’s corner radius once it’s in the grid, in px. Default: its own.
data-staggerHow much later the outer images settle than the near ones, from 0 to 0.6. Default 0.15.

It pins with position: sticky, so nothing around it can have overflow: hidden. 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 () {
    // Zoom-out to Grid: one image fills the screen, then as you scroll the
    // view pulls back and it shrinks into its place in a grid of other images,
    // which slide in around it. Scroll back up and it zooms in again.
    // Usage: <section data-zoom-out-to-grid>
    //          <div class="my-grid">                (the grid: any layout)
    //            <img src="a.jpg" alt="…">
    //            <figure data-hero>…</figure>        (the one that starts full screen)
    //            <img src="c.jpg" alt="…">
    //          </div>
    //        </section>
    // Anything inside the hero marked data-caption fades away as it shrinks.
    // Optional: data-length (how many screen heights of scrolling the zoom
    // takes while pinned, default 1.2), data-radius (the hero's corner radius
    // once in the grid, px, default: its own), data-stagger (how much later
    // the outer images settle than the near ones, 0 to 0.6, default 0.15).
    // Works inside scrolling boxes too: it follows the nearest scrolling parent.
    var DEFAULTS = { length: 1.2, stagger: 0.15 };

    var css =
      '.zog{position:relative}' +
      '.zog-pin{position:sticky;top:0;display:grid;place-items:center;overflow:hidden}' +
      '.zog-hero{position:relative;z-index:1;will-change:transform}' +
      '.zog-tile{will-change:transform}';
    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 < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; }

    function setup(el) {
      var d = el.dataset;
      var length = Math.max(0.2, num(d.length, DEFAULTS.length));
      var stagger = Math.max(0, Math.min(0.6, num(d.stagger, DEFAULTS.stagger)));
      var grid = el.firstElementChild;
      var hero = el.querySelector('[data-hero]');
      if (!grid || !hero) return;
      var box = scroller(el);
      var captions = hero.querySelectorAll('[data-caption]');
      var radius = num(d.radius, parseFloat(getComputedStyle(hero).borderTopLeftRadius) || 0);
      var tiles = Array.prototype.filter.call(grid.children, function (c) { return c !== hero; });

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

      var viewW = 0, viewH = 0, queued = false, last = -1;
      var cover = 1, dx = 0, dy = 0, far = [];

      function measure() {
        viewH = box ? box.clientHeight : window.innerHeight;
        viewW = pin.clientWidth || el.clientWidth;
        pin.style.height = viewH + 'px';
        el.style.height = viewH * (1 + length) + 'px';
        // Where the hero sits in the grid, untransformed.
        hero.style.transform = '';
        tiles.forEach(function (t) { t.style.transform = ''; });
        var pr = pin.getBoundingClientRect();
        var hr = hero.getBoundingClientRect();
        var hx = hr.left + hr.width / 2, hy = hr.top + hr.height / 2;
        cover = Math.max(viewW / (hr.width || 1), viewH / (hr.height || 1)) * 1.02;
        dx = pr.left + viewW / 2 - hx;
        dy = pr.top + viewH / 2 - hy;
        // Each tile's offset from the hero, and how far out it is (for the stagger).
        var maxD = 1;
        far = tiles.map(function (t) {
          var r = t.getBoundingClientRect();
          var ox = r.left + r.width / 2 - hx, oy = r.top + r.height / 2 - hy;
          var dist = Math.sqrt(ox * ox + oy * oy);
          maxD = Math.max(maxD, dist);
          return { ox: ox, oy: oy, d: dist };
        });
        far.forEach(function (f) { f.k = f.d / maxD; });
        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 (Math.abs(p - last) < 0.0005) return;
        last = p;
        var e = ease(p);
        var s = cover + (1 - cover) * e;
        hero.style.transform = e >= 1 ? '' : 'translate3d(' + (dx * (1 - e)).toFixed(2) + 'px,' + (dy * (1 - e)).toFixed(2) + 'px,0) scale(' + s.toFixed(4) + ')';
        // Keep the corners square while full screen, rounding as it lands.
        hero.style.borderRadius = (radius * e / s).toFixed(2) + 'px';
        // Captions keep their own size while the hero is blown up, and fade as it shrinks.
        for (var c = 0; c < captions.length; c++) {
          captions[c].style.opacity = Math.max(0, 1 - p * 3).toFixed(3);
          captions[c].style.transform = 'scale(' + (1 / s).toFixed(4) + ')';
        }
        // The other tiles ride the same zoom (pushed out from the hero), the far ones a little later.
        for (var i = 0; i < tiles.length; i++) {
          var f = far[i];
          var lp = Math.max(0, Math.min(1, (p - stagger * f.k) / (1 - stagger)));
          var le = ease(lp);
          var ls = cover + (1 - cover) * le;
          var push = ls - 1;
          tiles[i].style.transform = le >= 1 ? '' : 'translate3d(' + (dx * (1 - le) + f.ox * push).toFixed(2) + 'px,' + (dy * (1 - le) + f.oy * push).toFixed(2) + 'px,0) scale(' + ls.toFixed(4) + ')';
          tiles[i].style.opacity = Math.min(1, le * 1.6).toFixed(3);
        }
      }

      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-zoom-out-to-grid]').forEach(function (el) {
        if (el._zog) return;
        el._zog = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section data-zoom-out-to-grid>
  <div class="gallery">
    <img src="1.jpg" alt="…">
    <img src="2.jpg" alt="…">
    <figure data-hero>
      <img src="hero.jpg" alt="…">
      <figcaption data-caption>Explore</figcaption>
    </figure>
    <img src="4.jpg" alt="…">
  </div>
</section>