← Page transitions

Pixel Dissolve Transition

A page transition: when a link is clicked, square pixels of colour pop up at random until the page is covered, and on the next page they clear away at random to reveal it. As a slideshow, the picture itself breaks into pixels that vanish one by one. 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-pixel-dissolve-transition to each page’s <body>.

Options

Add any of these attributes to the same element.

data-pixel-dissolve-transition="#hex"Pixel colour. Default #6a0dad.
data-sizeRough pixel size. Default 40 px (a little smaller in small slideshows).
data-durationTime for each half. 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, and if anything goes wrong the link still opens. Coming back with the Back button clears the pixels again. Put data-pixel-dissolve-transition on any other element with two or more children and each child dissolves 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 () {
    // Pixel Dissolve Transition: the view breaks into square pixels that clear
    // in random order. As a page transition, pixels of colour pop in at random
    // to cover the page when a link is clicked, and on the next page they clear
    // away at random to reveal it.
    // Usage: <body data-pixel-dissolve-transition>   (add the snippet to every page)
    // Optional: data-pixel-dissolve-transition="#hex" (pixel colour, default
    // #6a0dad), data-size (rough pixel size in px, default 40),
    // data-duration (ms for each half, default 700).
    // Contained mode: put data-pixel-dissolve-transition on any other element
    // with two or more children. Every data-interval ms (default 2200) the
    // current child breaks into pixels that vanish in random order, revealing
    // the next one underneath.
    var DEFAULTS = { color: '#6a0dad', size: 40, duration: 700, interval: 2200 };

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

    // A grid of square-ish cells over a w x h box, each with a random moment
    // (0 to 1) at which it flips.
    function grid(w, h, size) {
      var cols = Math.max(2, Math.round(w / size)), rows = Math.max(2, Math.round(h / size)), at = [];
      for (var i = 0; i < cols * rows; i++) at.push(Math.random());
      return { cols: cols, rows: rows, at: at };
    }

    // ---------- Page mode: coloured pixels on a canvas ----------

    function layer(body, color) {
      var c = document.createElement('canvas');
      c.className = 'pxd-layer';
      c.setAttribute('aria-hidden', 'true');
      body.appendChild(c);
      return { c: c, ctx: c.getContext('2d'), color: color };
    }

    // Cover: pixels pop in at random. Uncover: they clear at random.
    function run(L, cover, size, duration, done) {
      if (L.anim) L.anim.pause();
      var w = innerWidth, h = innerHeight, dpr = Math.min(2, window.devicePixelRatio || 1);
      L.c.width = Math.round(w * dpr);
      L.c.height = Math.round(h * dpr);
      L.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      var g = grid(w, h, size), cw = w / g.cols, ch = h / g.rows, s = { t: 0 };
      var draw = function (t) {
        L.ctx.clearRect(0, 0, w, h);
        L.ctx.fillStyle = L.color;
        for (var i = 0; i < g.at.length; i++) {
          if ((g.at[i] < t) !== cover) continue;
          var c = i % g.cols, r = (i / g.cols) | 0;
          // Round outwards so neighbouring pixels leave no hairline gaps.
          L.ctx.fillRect(Math.floor(c * cw), Math.floor(r * ch), Math.ceil(cw) + 1, Math.ceil(ch) + 1);
        }
      };
      L.c.style.visibility = 'visible';
      draw(0);
      L.anim = anime.animate(s, {
        t: 1,
        duration: duration,
        ease: 'linear',
        onUpdate: function () { draw(s.t); },
        onComplete: function () {
          draw(1.01);
          if (!cover) L.c.style.visibility = 'hidden';
          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 size = +body.dataset.size || DEFAULTS.size;
      var L = layer(body, body.getAttribute('data-pixel-dissolve-transition') || DEFAULTS.color);
      var trip = 0;
      run(L, false, size, 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; };
        try {
          run(L, true, size, duration, go);
          setTimeout(go, duration + 400);
        } catch (err) { go(); }
      });
      window.addEventListener('pageshow', function (e) {
        if (!e.persisted) return;
        trip++;
        run(L, false, size, duration);
      });
    }

    // ---------- Contained mode: the real view breaks up ----------

    // A mask that keeps only the cells still standing, drawn as crisp SVG
    // squares so it scales to any size without blurring.
    function mask(g, t) {
      var d = '';
      for (var i = 0; i < g.at.length; i++) {
        if (g.at[i] < t) continue;
        d += 'M' + (i % g.cols) + ' ' + ((i / g.cols) | 0) + 'h1v1h-1z';
      }
      var svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + g.cols + ' ' + g.rows +
        '" preserveAspectRatio="none" shape-rendering="crispEdges"><path d="' + d + '"/></svg>';
      return 'url("data:image/svg+xml,' + encodeURIComponent(svg) + '")';
    }

    function setMask(node, m) {
      node.style.webkitMaskImage = m;
      node.style.maskImage = m;
      var size = m === 'none' ? '' : '100% 100%';
      node.style.webkitMaskSize = size;
      node.style.maskSize = size;
    }

    function containedMode(el, reduced) {
      el.classList.add('pxd-contained');
      var slides = Array.from(el.children);
      var duration = +el.dataset.duration || DEFAULTS.duration;
      var i = 0;
      slides.forEach(function (s, n) { s.style.visibility = n ? 'hidden' : 'visible'; });
      if (reduced) return;
      var size = +el.dataset.size || Math.max(18, Math.min(DEFAULTS.size, el.clientWidth / 16));
      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];
          var g = grid(el.clientWidth, el.clientHeight, +el.dataset.size || size), s = { t: 0 }, shown = -1;
          // The old view sits on top and loses pixels; the new one waits beneath.
          from.style.zIndex = 1;
          to.style.zIndex = 0;
          to.style.visibility = 'visible';
          anime.animate(s, {
            t: 1,
            duration: duration,
            ease: 'linear',
            onUpdate: function () {
              // Rebuild the mask at most 60 times over the whole dissolve.
              var step = Math.round(s.t * 60);
              if (step === shown) return;
              shown = step;
              setMask(from, mask(g, s.t));
            },
            onComplete: function () {
              from.style.visibility = 'hidden';
              from.style.zIndex = to.style.zIndex = '';
              setMask(from, '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-pixel-dissolve-transition]').forEach(function (el) {
        if (el._pxd) return;
        el._pxd = 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-pixel-dissolve-transition>
  …
</body>