← Page transitions

Page Fade

A page transition: the page fades out to its background colour when a link is clicked, and the next page fades in from it. Quiet and smooth, it suits any site. This page uses it, so try any link.

Page one
Page two

How to use it

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

Options

Add any of these attributes to the same element.

data-page-fade="#hex"The colour it fades through. Default: the page’s own background.
data-durationTime for each fade. Default 450 ms.
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 fades the page in again. Put data-page-fade on any other element with two or more children and it becomes a slideshow that fades between them.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Page Fade: a page transition. When a link is clicked the page fades out
    // to its background colour, and the next page fades in from it. Quiet,
    // smooth and suits any site.
    // Usage: <body data-page-fade>   (add the snippet to every page)
    // Optional: data-page-fade="#hex" (the colour it fades through, default the
    // page's own background), data-duration (ms, default 450).
    // Contained mode: put data-page-fade on any other element with two or more
    // children and it fades between them every data-interval ms (a slideshow).
    var DEFAULTS = { duration: 450, interval: 2200 };

    var css =
      '.pfd-cover{position:fixed;inset:0;z-index:2147483000;pointer-events:none;opacity:0;visibility:hidden}' +
      '.pfd-contained{position:relative;overflow:hidden;display:grid}' +
      '.pfd-contained>*:not(.pfd-cover){grid-area:1/1}' +
      '.pfd-contained>.pfd-cover{position:absolute}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    // The colour to fade through: the attribute, else the element's (or the
    // page's) own background, else white.
    function colorOf(el) {
      var c = el.getAttribute('data-page-fade');
      if (c) return c;
      var nodes = [el, document.documentElement];
      for (var i = 0; i < nodes.length; i++) {
        var bg = getComputedStyle(nodes[i]).backgroundColor;
        if (bg && bg !== 'transparent' && !/rgba\(.*,\s*0\)$/.test(bg)) return bg;
      }
      return '#fff';
    }

    function cover(parent, color) {
      var c = document.createElement('div');
      c.className = 'pfd-cover';
      c.setAttribute('aria-hidden', 'true');
      c.style.background = color;
      parent.appendChild(c);
      return c;
    }

    function fade(c, to, duration, done) {
      if (c._pfd) c._pfd.pause();
      c.style.visibility = 'visible';
      c._pfd = anime.animate(c, {
        opacity: to, duration: duration, ease: to ? 'in(2)' : 'out(2)',
        onComplete: function () { if (!to) c.style.visibility = 'hidden'; 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 c = cover(body, colorOf(body));
      var trip = 0;
      anime.utils.set(c, { opacity: 1 });
      fade(c, 0, duration);

      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; };
        // Never hold up the visit: go anyway if the fade stalls or fails.
        setTimeout(go, duration + 400);
        try { fade(c, 1, duration, go); } catch (err) { go(); }
      });
      // Coming back with the Back button can restore the faded-out page: fade it
      // back in, and cancel the visit that page was on its way to.
      window.addEventListener('pageshow', function (e) {
        if (!e.persisted) return;
        trip++;
        fade(c, 0, duration);
      });
    }

    function containedMode(el, reduced) {
      el.classList.add('pfd-contained');
      var slides = Array.from(el.children);
      var duration = +el.dataset.duration || DEFAULTS.duration;
      var c = cover(el, colorOf(el));
      var i = 0;
      slides.forEach(function (s, n) { s.style.visibility = n ? 'hidden' : 'visible'; });
      if (reduced) return;
      // Fade out, swap, fade in: on a timer that only runs while it's on screen.
      var timer = anime.createTimer({
        duration: (+el.dataset.interval || DEFAULTS.interval) + duration * 2,
        loop: true,
        autoplay: false,
        onLoop: function () {
          if (el.closest('[hidden]')) return;
          fade(c, 1, duration, function () {
            slides[i].style.visibility = 'hidden';
            i = (i + 1) % slides.length;
            slides[i].style.visibility = 'visible';
            setTimeout(function () { fade(c, 0, duration); }, 80);
          });
        }
      });
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) timer.play(); else timer.pause();
      }).observe(el);
    }

    function init() {
      var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
      document.querySelectorAll('[data-page-fade]').forEach(function (el) {
        if (el._pfd) return;
        el._pfd = true;
        if (el === document.body) { if (!reduced) pageMode(el); }
        else if (el.children.length > 1) containedMode(el, reduced);
      });
    }

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