← Page transitions

Halftone Dot Wipe

A page transition: when a link is clicked, a wave of halftone dots sweeps across the screen, each dot swelling until they merge and cover it, like a printed picture going solid. On the next page the dots shrink away in the same direction. 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-halftone-dot-wipe to each page’s <body>.

Options

Add any of these attributes to the same element.

data-halftone-dot-wipe="#hex"Dot colour. Default #1e1b4b.
data-sizeGap between dots. Default 26 px.
data-directionWhere the wave comes from: diagonal, left, right, up, down or centre. Default diagonal.
data-durationTime to cover the screen, and again to clear it. Default 750 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 clears the dots away again. Put data-halftone-dot-wipe on any other element with two or more children and it becomes a slideshow.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Halftone Dot Wipe: a wave of growing halftone dots sweeps across the
    // screen until they merge and cover it, then the dots shrink away in the
    // same direction to show the next view, like a printed halftone screen.
    // Usage: <body data-halftone-dot-wipe>   (add the snippet to every page)
    // Optional: data-halftone-dot-wipe="#hex" (dot colour, default #1e1b4b),
    // data-size (gap between dots in px, default 26), data-direction
    // ("diagonal", the default, "left", "right", "up", "down" or "centre"),
    // data-duration (ms to cover, and again to clear, default 750).
    // Contained mode: put data-halftone-dot-wipe on any other element with two
    // or more children and it wipes between them every data-interval ms
    // (default 2200).
    var DEFAULTS = { color: '#1e1b4b', size: 26, direction: 'diagonal', duration: 750, interval: 2200 };
    var KEY = 'animacious-halftone-dot';

    var css =
      'html.hdw-pre body{opacity:0}' +
      '.hdw-layer{position:fixed;inset:0;width:100%;height:100%;z-index:2147483000;pointer-events:none;display:block}' +
      '.hdw-contained{position:relative;overflow:hidden;display:grid}' +
      '.hdw-contained>*:not(.hdw-layer){grid-area:1/1}' +
      '.hdw-contained>.hdw-layer{position:absolute;z-index:2}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    var arriving = false;
    try { arriving = sessionStorage.getItem(KEY) === '1'; sessionStorage.removeItem(KEY); } catch (e) {}
    function unhide() { document.documentElement.classList.remove('hdw-pre'); }
    if (arriving && !reduced()) {
      document.documentElement.classList.add('hdw-pre');
      setTimeout(unhide, 3000);
    }

    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 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;
    }

    // A canvas of dots over `parent`. Each dot gets a start time from where it
    // sits along the wave, so the dots swell in a sweeping band.
    function layer(parent, opts) {
      var c = document.createElement('canvas');
      c.className = 'hdw-layer';
      c.setAttribute('aria-hidden', 'true');
      parent.appendChild(c);
      var w = c.clientWidth, h = c.clientHeight, dpr = Math.min(2, window.devicePixelRatio || 1);
      c.width = Math.round(w * dpr);
      c.height = Math.round(h * dpr);
      var ctx = c.getContext('2d');
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      var gap = Math.max(10, opts.size);
      var cols = Math.ceil(w / gap) + 1, rows = Math.ceil(h / (gap * 0.866)) + 1, dots = [];
      for (var r = 0; r < rows; r++) {
        for (var k = 0; k < cols + 1; k++) {
          // Alternate rows shift half a step, like a real halftone screen.
          var x = (k - (r % 2 ? 0.5 : 0)) * gap, y = r * gap * 0.866;
          dots.push({ x: x, y: y, at: place(opts.direction, x / w, y / h) });
        }
      }
      return { c: c, ctx: ctx, w: w, h: h, gap: gap, dots: dots, color: opts.color };
    }

    // 0 for the first dots the wave reaches, 1 for the last.
    function place(dir, u, v) {
      u = Math.max(0, Math.min(1, u));
      v = Math.max(0, Math.min(1, v));
      if (dir === 'left') return 1 - u;
      if (dir === 'right') return u;
      if (dir === 'up') return 1 - v;
      if (dir === 'down') return v;
      if (dir === 'centre' || dir === 'center') return Math.min(1, Math.hypot(u - 0.5, (v - 0.5) * 0.8) / 0.64);
      return (u * 0.6 + v * 0.4);
    }

    // `t` 0 to 1. Covering, each dot grows from nothing to big enough to merge
    // with its neighbours; clearing, it shrinks back to nothing.
    var SPREAD = 0.55;
    function paint(L, t, covering) {
      var ctx = L.ctx, full = L.gap * 0.62;
      ctx.clearRect(0, 0, L.w, L.h);
      ctx.fillStyle = L.color;
      var all = true;
      ctx.beginPath();
      for (var i = 0; i < L.dots.length; i++) {
        var d = L.dots[i];
        var p = (t - d.at * SPREAD) / (1 - SPREAD);
        p = p < 0 ? 0 : p > 1 ? 1 : p;
        p = p * p * (3 - 2 * p);
        var r = full * (covering ? p : 1 - p);
        if (r < full) all = false;
        if (r < 0.3) continue;
        ctx.moveTo(d.x + r, d.y);
        ctx.arc(d.x, d.y, r, 0, Math.PI * 2);
      }
      ctx.fill();
      // Fully grown dots leave tiny gaps between them, so fill those in at the end.
      if (all) ctx.fillRect(0, 0, L.w, L.h);
    }

    function optsOf(node) {
      return {
        color: node.getAttribute('data-halftone-dot-wipe') || DEFAULTS.color,
        size: +node.dataset.size || DEFAULTS.size,
        direction: node.dataset.direction || DEFAULTS.direction
      };
    }

    // ---------- Page mode: dots cover the page between links ----------

    function pageMode(body) {
      var duration = +body.dataset.duration || DEFAULTS.duration;
      var opts = optsOf(body);
      var L = null, anim, trip = 0;

      function run(out, done) {
        if (anim) anim.pause();
        if (L) L.c.remove();
        L = layer(body, opts);
        anim = tween(duration, 'linear', function (t) { paint(L, t, out); }, function () {
          if (!out) { L.c.remove(); L = null; }
          if (done) done();
        });
      }
      if (arriving) run(false);
      unhide();

      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) {}
        setTimeout(go, duration + 400);
        try { run(true, go); } catch (err) { go(); }
      });
      window.addEventListener('pageshow', function (e) {
        if (!e.persisted) return;
        trip++;
        try { sessionStorage.removeItem(KEY); } catch (err) {}
        run(false);
      });
    }

    // ---------- Contained mode: dots wipe from one child to the next ----------

    function containedMode(el, still) {
      el.classList.add('hdw-contained');
      var slides = Array.from(el.children);
      var duration = +el.dataset.duration || DEFAULTS.duration;
      var opts = optsOf(el);
      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 * 2 + 80,
        loop: true,
        autoplay: false,
        onLoop: function () {
          if (el.closest('[hidden]') || !el.clientWidth) return;
          var n = (i + 1) % slides.length;
          var L = layer(el, opts);
          tween(duration, 'linear', function (t) { paint(L, t, true); }, function () {
            slides[i].style.visibility = 'hidden';
            slides[n].style.visibility = 'visible';
            i = n;
            setTimeout(function () {
              tween(duration, 'linear', function (t) { paint(L, t, false); }, function () { L.c.remove(); });
            }, 80);
          });
        }
      });
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) timer.play(); else timer.pause();
      }).observe(el);
    }

    function init() {
      var still = reduced();
      document.querySelectorAll('[data-halftone-dot-wipe]').forEach(function (el) {
        if (el._hdw) return;
        el._hdw = 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-halftone-dot-wipe>
  …
</body>