← Page transitions

Zoom Through Transition

A page transition: when a link is clicked the page rushes towards you and fades, as if you’d flown straight through it, and the next page flies in from the distance and settles into place. This page uses it, so try any link.

How to use it

  1. Add the snippet to every page of your site.
  2. Add data-zoom-through-transition to each page’s <body>.

Options

Add any of these attributes to the same element.

data-zoom-through-transition="#hex"The colour seen behind the page mid-zoom. Default #0f0b1a.
data-durationTime for each half of a page zoom. Default 600 ms (1000 ms for a whole slideshow change).
data-intervalContained mode only: time each slide stays. Default 2200 ms.

Links to other sites, new tabs, downloads and #anchors are left alone, and if anything goes wrong the link still opens. Coming back with the Back button zooms the page in again. Put data-zoom-through-transition on any other element with two or more children and each child zooms past into the next, like the demo above.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Zoom Through Transition: the view zooms in towards you and out into the
    // next. As a page transition, the page rushes past the viewer and fades when
    // a link is clicked, and the next page flies in from the distance.
    // Usage: <body data-zoom-through-transition>   (add the snippet to every page)
    // Optional: data-zoom-through-transition="#hex" (the colour seen behind the
    // page mid-zoom, default #0f0b1a), data-duration (ms for each half,
    // default 600 on a page, 1000 for a whole slideshow change).
    // Contained mode: put data-zoom-through-transition on any other element with
    // two or more children. Every data-interval ms (default 2200) the current
    // child zooms past and the next one arrives from far away.
    var DEFAULTS = { color: '#0f0b1a', duration: 600, slideDuration: 1000, interval: 2200 };
    var KEY = 'animacious-zoom-through';

    var css =
      'html.zth-pre body{opacity:0}' +
      '.zth-contained{position:relative;overflow:hidden;display:grid}' +
      '.zth-contained>*{grid-area:1/1;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; }

    // Arriving from a link that used the transition: hide the page before it
    // first paints, so the zoom in starts cleanly. Never for more than 3 s.
    var arriving = false;
    try { arriving = sessionStorage.getItem(KEY) === '1'; sessionStorage.removeItem(KEY); } catch (e) {}
    function unhide() { document.documentElement.classList.remove('zth-pre'); }
    if (arriving && !reduced()) {
      document.documentElement.classList.add('zth-pre');
      setTimeout(unhide, 3000);
    }

    // Tweens t from 0 to 1, calling paint(t) on every frame.
    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(); }
      });
    }

    // Same-site links that should get the transition: not new tabs, downloads,
    // other sites or #anchors on this page.
    function linkFor(e) {
      var a = e.target.closest && e.target.closest('a[href]');
      if (!a || e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return null;
      if ((a.target && a.target !== '_self') || a.hasAttribute('download')) return null;
      var url = new URL(a.href, location.href);
      if (url.origin !== location.origin) return null;
      if (url.pathname === location.pathname && url.search === location.search && url.hash) return null;
      return url;
    }

    function pageMode(body) {
      var duration = +body.dataset.duration || DEFAULTS.duration;
      var color = body.getAttribute('data-zoom-through-transition') || DEFAULTS.color;
      var html = document.documentElement, saved = null, anim, trip = 0;

      // Leaving: the page grows past you and fades. Arriving: it flies in from
      // far away. The page's backdrop shows the chosen colour meanwhile.
      function run(out, done) {
        if (anim) anim.pause();
        if (saved === null) saved = { bg: html.style.background, ox: html.style.overflowX };
        html.style.background = color;
        html.style.overflowX = 'hidden';
        body.style.transformOrigin = '50% ' + (window.scrollY + innerHeight / 2) + 'px';
        anim = tween(duration, out ? 'in(3)' : 'out(3)', function (t) {
          var k = out ? t : 1 - t;
          body.style.transform = 'scale(' + (out ? 1 + 1.6 * k : 1 - 0.45 * k) + ')';
          body.style.opacity = 1 - k;
        }, function () {
          if (!out) {
            body.style.transform = body.style.opacity = body.style.transformOrigin = '';
            html.style.background = saved.bg;
            html.style.overflowX = saved.ox;
            saved = null;
          }
          if (done) done();
        });
      }
      run(false);

      document.addEventListener('click', function (e) {
        var url = linkFor(e);
        if (!url) return;
        e.preventDefault();
        var t = ++trip, gone = false;
        var go = function () { if (gone || t !== trip) return; gone = true; location.href = url.href; };
        try { sessionStorage.setItem(KEY, '1'); } catch (err) {}
        // Never hold up the visit: go anyway if the zoom stalls or fails.
        setTimeout(go, duration + 400);
        try { run(true, go); } catch (err) { go(); }
      });
      // Back from the next page: the page may be restored mid-zoom.
      window.addEventListener('pageshow', function (e) {
        if (!e.persisted) return;
        trip++;
        try { sessionStorage.removeItem(KEY); } catch (err) {}
        run(false);
      });
    }

    function containedMode(el, still) {
      el.classList.add('zth-contained');
      el.style.background = el.getAttribute('data-zoom-through-transition') || DEFAULTS.color;
      var slides = Array.from(el.children);
      var duration = +el.dataset.duration || DEFAULTS.slideDuration;
      var i = 0;
      slides.forEach(function (s, n) { s.style.visibility = n ? 'hidden' : 'visible'; });
      if (still) return;

      var timer = anime.createTimer({
        duration: (+el.dataset.interval || DEFAULTS.interval) + duration,
        loop: true,
        autoplay: false,
        onLoop: function () {
          if (el.closest('[hidden]')) return;
          var from = slides[i], to = slides[(i + 1) % slides.length];
          from.style.zIndex = 1;
          to.style.zIndex = 0;
          to.style.visibility = 'visible';
          // The two overlap in the middle: the old view is still rushing past
          // as the new one comes into focus.
          tween(duration, 'linear', function (t) {
            var a = Math.min(1, t / 0.6), b = Math.max(0, (t - 0.35) / 0.65);
            a = a * a * a;                       // in(3): speeds up towards you
            b = 1 - Math.pow(1 - b, 3);          // out(3): settles into place
            from.style.transform = 'scale(' + (1 + 1.5 * a) + ')';
            from.style.opacity = 1 - a;
            from.style.filter = 'blur(' + (a * 8).toFixed(2) + 'px)';
            to.style.transform = 'scale(' + (0.45 + 0.55 * b) + ')';
            to.style.opacity = b;
            to.style.filter = b < 1 ? 'blur(' + ((1 - b) * 8).toFixed(2) + 'px)' : '';
          }, function () {
            from.style.visibility = 'hidden';
            from.style.transform = from.style.opacity = from.style.filter = from.style.zIndex = '';
            to.style.transform = to.style.opacity = to.style.filter = to.style.zIndex = '';
            i = (i + 1) % slides.length;
          });
        }
      });
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) timer.play(); else timer.pause();
      }).observe(el);
    }

    function init() {
      var still = reduced();
      document.querySelectorAll('[data-zoom-through-transition]').forEach(function (el) {
        if (el._zth) return;
        el._zth = true;
        if (el === document.body) { if (!still) pageMode(el); }
        else if (el.children.length > 1) containedMode(el, still);
      });
      unhide();
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<body data-zoom-through-transition>
  …
</body>