← Page transitions

Iris Wipe

A page transition: a circle closes in on the spot you clicked until the screen goes dark, then opens out again on the next page, like the end of an old cartoon. This page uses it, so try any link.

Scene one
Scene two

How to use it

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

Options

Add any of these attributes to the same element.

data-iris-wipe="#hex"Colour. Default #0a0a0b.
data-durationTime for each close or open. Default 700 ms.
data-intervalContained mode only: time each slide stays. Default 2200 ms.

Links to other sites, new tabs, downloads and #anchors are left alone. Put data-iris-wipe on any other element with two or more children and it becomes a slideshow that irises between them.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Iris Wipe: a page transition. When a link is clicked, a circle closes in
    // on the spot you clicked until the screen is covered; on the next page it
    // opens out again, like the end of an old cartoon.
    // Usage: <body data-iris-wipe>   (add the snippet to every page)
    // Optional: data-iris-wipe="#0a0a0b" (colour, default near-black),
    // data-duration (ms, default 700).
    // Contained mode: put data-iris-wipe on any other element with two or more
    // children and it irises between them every data-interval ms (a slideshow).
    var DEFAULTS = { color: '#0a0a0b', duration: 700, interval: 2200 };
    var KEY = 'animacious-iris';

    var css =
      '.iwx-panel{position:fixed;inset:0;z-index:2147483000;pointer-events:none;visibility:hidden;' +
      '-webkit-mask-image:radial-gradient(circle at var(--iwx-x) var(--iwx-y),transparent var(--iwx-r),#000 calc(var(--iwx-r) + 1px));' +
      'mask-image:radial-gradient(circle at var(--iwx-x) var(--iwx-y),transparent var(--iwx-r),#000 calc(var(--iwx-r) + 1px))}' +
      '.iwx-contained{position:relative;overflow:hidden;display:grid}' +
      '.iwx-contained>*:not(.iwx-panel){grid-area:1/1}' +
      '.iwx-contained>.iwx-panel{position:absolute}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    // Animate the hole's radius from one size to another around (x, y).
    function iris(p, x, y, from, to, duration, done) {
      var box = p.getBoundingClientRect();
      // Big enough to clear the farthest corner.
      var full = Math.hypot(Math.max(x, box.width - x), Math.max(y, box.height - y)) + 2;
      var s = { r: from * full };
      p.style.setProperty('--iwx-x', x + 'px');
      p.style.setProperty('--iwx-y', y + 'px');
      var apply = function () { p.style.setProperty('--iwx-r', s.r + 'px'); };
      apply();
      p.style.visibility = 'visible';
      // A new iris replaces one still running (e.g. a click while the page opens).
      if (p._iwx) p._iwx.pause();
      p._iwx = anime.animate(s, {
        r: to * full, duration: duration, ease: to ? 'in(3)' : 'inOut(3)', onUpdate: apply,
        onComplete: function () { if (to) p.style.visibility = 'hidden'; if (done) done(); }
      });
    }

    function pageMode(body) {
      var color = body.getAttribute('data-iris-wipe') || DEFAULTS.color;
      var duration = +body.dataset.duration || DEFAULTS.duration;
      var p = panel(body, color);
      // Open out from where the visitor clicked on the last page, if we know it.
      var at = null;
      try { at = JSON.parse(sessionStorage.getItem(KEY)); sessionStorage.removeItem(KEY); } catch (e) { /* storage blocked */ }
      var x = at ? at[0] * innerWidth : innerWidth / 2, y = at ? at[1] * innerHeight : innerHeight / 2;
      iris(p, x, y, 0, 1, duration);

      document.addEventListener('click', function (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;
        if (a.target && a.target !== '_self') return;
        if (a.hasAttribute('download')) return;
        var url = new URL(a.href, location.href);
        if (url.origin !== location.origin) return;
        // Same page, only the #hash changes: let the browser scroll.
        if (url.pathname === location.pathname && url.search === location.search && url.hash) return;
        e.preventDefault();
        try { sessionStorage.setItem(KEY, JSON.stringify([e.clientX / innerWidth, e.clientY / innerHeight])); } catch (err) { /* ignore */ }
        iris(p, e.clientX, e.clientY, 1, 0, duration, function () { location.href = url.href; });
      });
      // Coming back with the Back button can restore the covered page: open it.
      window.addEventListener('pageshow', function (e) {
        if (e.persisted) p.style.visibility = 'hidden';
      });
    }

    function containedMode(el) {
      el.classList.add('iwx-contained');
      var slides = Array.from(el.children);
      var color = el.getAttribute('data-iris-wipe') || DEFAULTS.color;
      var duration = +el.dataset.duration || DEFAULTS.duration;
      var p = panel(el, color);
      var i = 0;
      slides.forEach(function (s, n) { s.style.visibility = n ? 'hidden' : 'visible'; });
      setInterval(function () {
        if (el.closest('[hidden]')) return;
        var w = el.clientWidth, h = el.clientHeight;
        // Close in on a spot a little off-centre, then open out from the middle.
        iris(p, w * (0.35 + Math.random() * 0.3), h * (0.35 + Math.random() * 0.3), 1, 0, duration, function () {
          slides[i].style.visibility = 'hidden';
          i = (i + 1) % slides.length;
          slides[i].style.visibility = 'visible';
          setTimeout(function () { iris(p, w / 2, h / 2, 0, 1, duration); }, 150);
        });
      }, (+el.dataset.interval || DEFAULTS.interval) + duration * 2);
    }

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

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