← Page transitions

Clock Wipe

A page transition: when a link is clicked, colour sweeps round the screen from twelve o’clock like the hand of a clock until it covers the page, and on the next page the sweep carries on round to uncover it. 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-clock-wipe to each page’s <body>.

Options

Add any of these attributes to the same element.

data-clock-wipe="#hex"Sweep colour. Default #6a0dad.
data-durationTime for each sweep. Default 800 ms.
data-hand="false"Leave out the bright hand line along the sweep’s edge.
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 sweeps the page in again. Put data-clock-wipe on any other element with two or more children and each next child sweeps round over the last, like the demo above.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Clock Wipe: the next view sweeps round like the hand of a clock. As a
    // page transition, a coloured sweep covers the page clockwise when a link
    // is clicked, and on the next page the sweep carries on round to uncover it.
    // Usage: <body data-clock-wipe>   (add the snippet to every page)
    // Optional: data-clock-wipe="#hex" (sweep colour, default #6a0dad),
    // data-duration (ms for each sweep, default 800), data-hand="false" (no
    // bright hand line on the sweep's edge).
    // Contained mode: put data-clock-wipe on any other element with two or more
    // children. Every data-interval ms (default 2200) the next child sweeps
    // round over the current one.
    var DEFAULTS = { color: '#6a0dad', duration: 800, interval: 2200 };

    var css =
      '.clw-layer{position:fixed;inset:0;z-index:2147483000;pointer-events:none;visibility:hidden}' +
      '.clw-hand{position:absolute;left:50%;bottom:50%;width:2px;margin-left:-1px;transform-origin:50% 100%;pointer-events:none;' +
      'background:linear-gradient(to top,rgba(255,255,255,.95),rgba(255,255,255,.5));box-shadow:0 0 12px rgba(255,255,255,.7);opacity:0}' +
      '.clw-contained{position:relative;overflow:hidden;display:grid}' +
      '.clw-contained>*:not(.clw-hand){grid-area:1/1}' +
      '.clw-contained>.clw-hand{z-index:3}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function setMask(node, m) {
      node.style.webkitMaskImage = m;
      node.style.maskImage = m;
    }

    // Shown: the slice from 12 o'clock round to `deg`. Hidden: the rest of the
    // circle. A hair of feathering keeps the edge smooth.
    function wedge(deg, shown) {
      var a = shown ? '#000' : 'transparent', b = shown ? 'transparent' : '#000';
      return 'conic-gradient(from 0deg at 50% 50%, ' + a + ' ' + deg + 'deg, ' + b + ' ' + Math.min(360, deg + 0.4) + 'deg)';
    }

    function hand(parent, show) {
      var h = document.createElement('div');
      h.className = 'clw-hand';
      h.setAttribute('aria-hidden', 'true');
      if (!show) h.style.display = 'none';
      parent.appendChild(h);
      return h;
    }

    // Sweeps 0 to 360 degrees, calling paint(deg) on each frame.
    function sweep(box, handEl, duration, paint, done) {
      handEl.style.height = Math.ceil(Math.hypot(box.clientWidth, box.clientHeight) / 2 + 4) + 'px';
      var s = { a: 0 };
      paint(0);
      return anime.animate(s, {
        a: 360,
        duration: duration,
        ease: 'inOut(2)',
        onUpdate: function () {
          paint(s.a);
          handEl.style.transform = 'rotate(' + s.a + 'deg)';
          // The hand fades in as it sets off and out as it arrives.
          handEl.style.opacity = Math.min(1, s.a / 30, (360 - s.a) / 30);
        },
        onComplete: function () { paint(360); handEl.style.opacity = 0; if (done) done(); }
      });
    }

    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 l = document.createElement('div');
      l.className = 'clw-layer';
      l.setAttribute('aria-hidden', 'true');
      var tint = document.createElement('div');
      tint.style.cssText = 'position:absolute;inset:0;background:' + (body.getAttribute('data-clock-wipe') || DEFAULTS.color);
      l.appendChild(tint);
      var h = hand(l, body.dataset.hand !== 'false');
      body.appendChild(l);
      var trip = 0, anim;

      // Cover: the colour sweeps in. Uncover: the page sweeps back in after it.
      function run(cover, done) {
        if (anim) anim.pause();
        l.style.visibility = 'visible';
        anim = sweep(l, h, duration, function (deg) { setMask(tint, wedge(deg, cover)); }, function () {
          if (!cover) l.style.visibility = 'hidden';
          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 {
          run(true, go);
          setTimeout(go, duration + 400);
        } catch (err) { go(); }
      });
      window.addEventListener('pageshow', function (e) {
        if (!e.persisted) return;
        trip++;
        run(false);
      });
    }

    function containedMode(el, reduced) {
      el.classList.add('clw-contained');
      var slides = Array.from(el.children);
      var duration = +el.dataset.duration || DEFAULTS.duration;
      var h = hand(el, el.dataset.hand !== 'false');
      var i = 0;
      slides.forEach(function (s, n) { s.style.visibility = n ? 'hidden' : 'visible'; });
      if (reduced) 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];
          // The new view goes on top and is uncovered slice by slice.
          from.style.zIndex = 0;
          to.style.zIndex = 1;
          to.style.visibility = 'visible';
          sweep(el, h, duration, function (deg) { setMask(to, wedge(deg, true)); }, function () {
            from.style.visibility = 'hidden';
            from.style.zIndex = to.style.zIndex = '';
            setMask(to, 'none');
            i = (i + 1) % slides.length;
          });
        }
      });
      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-clock-wipe]').forEach(function (el) {
        if (el._clw) return;
        el._clw = 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-clock-wipe>
  …
</body>