← Page transitions

Overview Zoom

Like an app switcher: the view zooms out to show every page side by side as cards, the camera glides along the row to the next one, and zooms back in until it fills the frame again. Tap or click the demo to move on yourself.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-overview-zoom to a container whose children are the pages, one after another.

Options

Add any of these attributes to the same element.

data-overview-zoom="#hex"The colour behind the pages while zoomed out. Default #0f0b1a.
data-durationTime for the whole zoom out and back in. Default 1600 ms.
data-intervalTime between automatic changes. Default 2200 ms; 0 turns them off.

Tap or click to move on to the next page; automatic changes rest for a few seconds after that. Call animacious.overviewZoom(el, 1) or (el, -1) to drive it from your own buttons. With reduced motion the pages simply swap.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Overview Zoom: the view zooms out to show every page side by side, like
    // an app switcher, the camera glides along to the next one, and zooms back
    // in until it fills the frame.
    // Usage: <div data-overview-zoom><section>…</section><section>…</section>…</div>
    // Each child is a page. Tap or click to move on to the next one.
    // Optional: data-overview-zoom="#hex" (the colour behind the pages while
    // zoomed out, default #0f0b1a), data-duration (ms for the whole zoom out
    // and in, default 1600), data-interval (ms between automatic changes,
    // default 2200; "0" turns them off). Automatic changes rest for a while
    // after someone taps.
    // Script use: animacious.overviewZoom(el, 1) for next, (el, -1) for previous.
    var DEFAULTS = { color: '#0f0b1a', duration: 1600, interval: 2200 };

    var css =
      '.ovz-on{position:relative;overflow:hidden;display:grid}' +
      '.ovz-on>*{grid-area:1/1}' +
      '.ovz-on>.ovz-fx{position:absolute;inset:0;z-index:2;pointer-events:none;overflow:hidden}' +
      '.ovz-world{position:absolute;left:0;top:0;transform-origin:0 0;will-change:transform}' +
      '.ovz-card{position:absolute;top:0;overflow:hidden}' +
      '.ovz-card>.ovz-copy{position:absolute;left:0;top:0;margin:0;max-width:none}' +
      '.ovz-tap{cursor:pointer;-webkit-user-select:none;user-select:none}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function tween(duration, ease, paint, done) {
      var s = { t: 0 };
      paint(0);
      return anime.animate(s, {
        t: 1, duration: duration, ease: ease,
        onUpdate: function () { paint(s.t); },
        onComplete: function () { paint(1); if (done) done(); }
      });
    }

    function smooth(x) { x = Math.max(0, Math.min(1, x)); return x * x * x * (x * (x * 6 - 15) + 10); }

    function go(el, step) {
      var s = el._ovz;
      if (!s || s.busy || s.slides.length < 2) return;
      var N = s.slides.length, from = s.slides[s.i], n = (s.i + step + N) % N, to = s.slides[n];
      if (reduced()) {
        from.style.visibility = 'hidden';
        to.style.visibility = 'visible';
        s.i = n;
        return;
      }
      var W = el.clientWidth, H = el.clientHeight;
      if (!W || !H) return;
      s.busy = true;

      // Every page as a card on one long strip, side by side.
      var fx = document.createElement('div');
      fx.className = 'ovz-fx';
      fx.setAttribute('aria-hidden', 'true');
      fx.style.background = s.color;
      var world = document.createElement('div');
      world.className = 'ovz-world';
      var gap = W * 0.09, cards = [];
      s.slides.forEach(function (slide, k) {
        var card = document.createElement('div');
        card.className = 'ovz-card';
        card.style.left = k * (W + gap) + 'px';
        card.style.width = W + 'px';
        card.style.height = H + 'px';
        var copy = slide.cloneNode(true);
        copy.removeAttribute('id');
        copy.querySelectorAll('[id]').forEach(function (x) { x.removeAttribute('id'); });
        copy.classList.add('ovz-copy');
        copy.style.width = W + 'px';
        copy.style.height = H + 'px';
        copy.style.visibility = 'visible';
        card.appendChild(copy);
        world.appendChild(card);
        cards.push(card);
      });
      fx.appendChild(world);
      el.appendChild(fx);
      from.style.visibility = 'hidden';

      // Far enough out to see the whole strip, with a margin.
      var span = N * W + (N - 1) * gap;
      var zOut = Math.min(0.7, W * 0.88 / span);
      var mid = (N - 1) / 2;
      var glow = 0;   // a ring round the next page while zoomed out
      function camera(c, z) {
        var x = W / 2 - (c * (W + gap) + W / 2) * z, y = H / 2 - H / 2 * z;
        world.style.transform = 'translate(' + x.toFixed(2) + 'px,' + y.toFixed(2) + 'px) scale(' + z.toFixed(5) + ')';
        // Rounded corners and a shadow grow in as the cards shrink, in screen pixels.
        var k = (1 - z) / (1 - zOut);
        cards.forEach(function (card, j) {
          card.style.borderRadius = (14 * k / z).toFixed(2) + 'px';
          var ring = j === n ? glow : 0;
          card.style.boxShadow = '0 ' + (10 * k / z).toFixed(1) + 'px ' + (30 * k / z).toFixed(1) + 'px rgba(0,0,0,' + (0.45 * k).toFixed(3) + ')' +
            (ring > 0 ? ',0 0 0 ' + (3 / z).toFixed(2) + 'px rgba(255,255,255,' + ring.toFixed(3) + ')' : '');
          card.style.filter = j !== n && j !== s.i ? 'brightness(' + (1 - 0.25 * k).toFixed(3) + ')' : '';
        });
      }
      var c0 = s.i, lz0 = 0, lzO = Math.log(zOut);
      tween(s.duration, 'linear', function (t) {
        var c, lz;
        if (t < 0.42) {
          // Zoom out and drift to the middle of the strip.
          var a = smooth(t / 0.42);
          lz = lz0 + (lzO - lz0) * a;
          c = c0 + (mid - c0) * a;
        } else if (t < 0.58) {
          lz = lzO;
          c = mid;
        } else {
          // Then glide in to the next page.
          var b = smooth((t - 0.58) / 0.42);
          lz = lzO + (lz0 - lzO) * b;
          c = mid + (n - mid) * b;
        }
        glow = Math.sin(Math.PI * Math.max(0, Math.min(1, (t - 0.32) / 0.5))) * 0.9;
        camera(c, Math.exp(lz));
      }, function () {
        to.style.visibility = 'visible';
        fx.remove();
        s.i = n;
        s.busy = false;
      });
    }

    function setup(el) {
      el.classList.add('ovz-on');
      var s = el._ovz = {
        slides: Array.from(el.children),
        i: 0,
        busy: false,
        rest: 0,
        color: el.getAttribute('data-overview-zoom') || DEFAULTS.color,
        duration: +el.dataset.duration || DEFAULTS.duration
      };
      s.slides.forEach(function (c, n) { c.style.visibility = n ? 'hidden' : 'visible'; });
      var interval = el.dataset.interval != null ? +el.dataset.interval : DEFAULTS.interval;

      el.classList.add('ovz-tap');
      el.addEventListener('click', function () {
        s.rest = Date.now() + Math.max(4000, interval * 2);
        go(el, 1);
      });

      if (interval > 0 && !reduced()) {
        var timer = anime.createTimer({
          duration: interval + s.duration,
          loop: true,
          autoplay: false,
          onLoop: function () {
            if (Date.now() < s.rest || el.closest('[hidden]')) return;
            go(el, 1);
          }
        });
        new IntersectionObserver(function (entries) {
          if (entries[0].isIntersecting) timer.play(); else timer.pause();
        }).observe(el);
      }
    }

    window.animacious = window.animacious || {};
    window.animacious.overviewZoom = go;

    function init() {
      document.querySelectorAll('[data-overview-zoom]').forEach(function (el) {
        if (el._ovz || el.children.length < 2) return;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-overview-zoom>
  <section>First page…</section>
  <section>Second page…</section>
  <section>Third page…</section>
</div>