← Rotating words

Pixel Word Swap

The word in your sentence breaks into square pixels that scatter and fly to new spots, rebuilding themselves as the next word before it snaps back into crisp text.

Made of pixels

How to use it

  1. Paste the snippet just before </head>.
  2. Wrap the changing word in a <span> and list every word, separated by commas, in data-pixel-word-swap.

Options

Add any of these attributes to the same element.

data-holdHow long each word stays. Default 1800 ms.
data-durationTime for each swap. Default 1300 ms.
data-pixelPixel size in px. Default about a twelfth of the font size.
data-scatterHow far the pixels fly, in em. Default 0.35.

The pixels are drawn on a small canvas in the word’s own font and colour, which takes over only while the swap runs.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Pixel Word Swap: the word in your sentence breaks into square pixels that
    // scatter and fly to new spots, rebuilding themselves as the next word
    // before it snaps back into crisp text.
    // Usage: <h1>Built <span data-pixel-word-swap="bit, by, bit">bit</span></h1>
    // Optional: data-hold (ms per word, default 1800), data-duration (ms per
    // swap, default 1300), data-pixel (pixel size in px, default about a
    // twelfth of the font size), data-scatter (how far pixels fly, in em,
    // default 0.35).
    var DEFAULTS = { hold: 1800, duration: 1300, scatter: 0.35 };

    var css =
      '.pws{display:inline-block;position:relative;white-space:nowrap;vertical-align:baseline;text-align:left}' +
      '.pws-word{white-space:nowrap}' +
      '.pws-canvas{position:absolute;pointer-events:none;display:none}' +
      '.pws-base{display:inline-block;width:0;height:0;vertical-align:baseline}' +
      '.pws-probe{position:absolute;left:0;top:0;visibility:hidden;white-space:nowrap}' +
      '.pws-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function span(cls, parent, text) {
      var s = document.createElement('span');
      s.className = cls;
      if (text) s.textContent = text;
      parent.appendChild(s);
      return s;
    }

    function setup(el) {
      var words = (el.getAttribute('data-pixel-word-swap') || el.textContent)
        .split(',').map(function (w) { return w.trim(); }).filter(Boolean);
      el.classList.add('pws');
      el.textContent = '';
      span('pws-sr', el, words.join(', '));
      var out = span('pws-word', el, words[0]);
      // A zero-size marker on the baseline tells us where to draw the text.
      var base = span('pws-base', el);
      var probe = span('pws-probe', el);
      var canvas = document.createElement('canvas');
      canvas.className = 'pws-canvas';
      el.appendChild(canvas);
      [out, base, probe, canvas].forEach(function (n) { n.setAttribute('aria-hidden', 'true'); });
      return { el: el, words: words, out: out, base: base, probe: probe, canvas: canvas, i: 0, w: 0, busy: false, visible: false, started: false };
    }

    function measure(s, text) {
      s.probe.textContent = text;
      return s.probe.offsetWidth;
    }

    function fit(s) {
      if (s.busy) return;
      s.w = measure(s, s.words[s.i]);
      s.el.style.width = s.w + 'px';
    }

    // Draw the word off screen and read back which grid cells are inked.
    function sample(text, font, spacing, baseline, w, h, g) {
      var c = document.createElement('canvas');
      c.width = Math.ceil(w);
      c.height = Math.ceil(h);
      var ctx = c.getContext('2d');
      ctx.font = font;
      if ('letterSpacing' in ctx) ctx.letterSpacing = spacing;
      ctx.textBaseline = 'alphabetic';
      ctx.fillStyle = '#000';
      ctx.fillText(text, 0, baseline);
      var data = ctx.getImageData(0, 0, c.width, c.height).data;
      var pts = [];
      for (var y = g / 2; y < c.height; y += g) {
        for (var x = g / 2; x < c.width; x += g) {
          if (data[(Math.floor(y) * c.width + Math.floor(x)) * 4 + 3] > 110) pts.push({ x: x, y: y });
        }
      }
      // Sorting left to right keeps each pixel's journey fairly short.
      pts.sort(function (a, b) { return a.x - b.x || a.y - b.y; });
      return pts;
    }

    function swap(s, done) {
      var d = s.el.dataset;
      var t = +d.duration || DEFAULTS.duration;
      var cs = getComputedStyle(s.el);
      var fs = parseFloat(cs.fontSize);
      var g = Math.max(2, +d.pixel || Math.round(fs / 12));
      var scatter = (d.scatter !== undefined ? +d.scatter : DEFAULTS.scatter) * fs;
      var font = cs.fontStyle + ' ' + cs.fontWeight + ' ' + cs.fontSize + ' ' + cs.fontFamily;
      var spacing = cs.letterSpacing === 'normal' ? '0px' : cs.letterSpacing;
      var from = s.words[s.i];
      s.i = (s.i + 1) % s.words.length;
      var to = s.words[s.i];
      var w0 = s.w;
      var w1 = measure(s, to);
      var h = Math.max(s.el.offsetHeight, s.out.offsetHeight) + fs * 0.2;
      var baseline = s.base.offsetTop;
      var pad = Math.ceil(scatter + g);
      var cw = Math.max(w0, w1) + fs * 0.3;
      var A = sample(from, font, spacing, baseline, cw, h, g);
      var B = sample(to, font, spacing, baseline, cw, h, g);
      if (!A.length || !B.length) { s.out.textContent = to; fit(s); done(); return; }

      // Pair the pixels up. Extra pixels borrow a partner's spot and fade.
      var n = Math.max(A.length, B.length);
      var parts = [];
      for (var k = 0; k < n; k++) {
        var ia = Math.floor(k * A.length / n);
        var ib = Math.floor(k * B.length / n);
        var a = A[ia];
        var b = B[ib];
        var ang = Math.random() * Math.PI * 2;
        var dist = scatter * (0.4 + Math.random() * 0.6);
        parts.push({
          ax: a.x, ay: a.y, bx: b.x, by: b.y,
          cx: (a.x + b.x) / 2 + Math.cos(ang) * dist, cy: (a.y + b.y) / 2 + Math.sin(ang) * dist,
          delay: Math.random() * 0.3,
          // A second pixel leaving the same spot fades in as it goes; a second
          // pixel arriving at the same spot fades out as it lands.
          fadeIn: k > 0 && ia === Math.floor((k - 1) * A.length / n),
          fadeOut: k > 0 && ib === Math.floor((k - 1) * B.length / n)
        });
      }

      var canvas = s.canvas;
      var dpr = Math.min(2, window.devicePixelRatio || 1);
      var W = cw + pad * 2;
      var H = h + pad * 2;
      canvas.style.left = -pad + 'px';
      canvas.style.top = -pad + 'px';
      canvas.style.width = W + 'px';
      canvas.style.height = H + 'px';
      canvas.width = Math.round(W * dpr);
      canvas.height = Math.round(H * dpr);
      var ctx = canvas.getContext('2d');
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      ctx.fillStyle = cs.color;
      var size = Math.max(1, g - (g > 3 ? 1 : 0.5));

      s.busy = true;
      canvas.style.display = 'block';
      s.out.style.visibility = 'hidden';
      var state = { p: 0 };
      anime.animate(state, {
        p: 1,
        duration: t,
        ease: 'linear',
        onUpdate: function () {
          var p = state.p;
          var e = p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2;
          s.w = w0 + (w1 - w0) * e;
          s.el.style.width = s.w.toFixed(2) + 'px';
          ctx.clearRect(0, 0, W, H);
          parts.forEach(function (q) {
            // Hold still as pixels for a beat, then travel along a curve.
            var u = Math.max(0, Math.min(1, (p - 0.1 - q.delay) / 0.55));
            u = u < 0.5 ? 2 * u * u : 1 - Math.pow(-2 * u + 2, 2) / 2;
            var v = 1 - u;
            var x = v * v * q.ax + 2 * v * u * q.cx + u * u * q.bx;
            var y = v * v * q.ay + 2 * v * u * q.cy + u * u * q.by;
            ctx.globalAlpha = q.fadeIn ? u : q.fadeOut ? 1 - u : 1;
            ctx.fillRect(x - size / 2 + pad, y - size / 2 + pad, size, size);
          });
          ctx.globalAlpha = 1;
        },
        onComplete: function () {
          s.out.textContent = to;
          s.out.style.visibility = '';
          canvas.style.display = 'none';
          s.w = w1;
          s.el.style.width = w1 + 'px';
          s.busy = false;
          done();
        }
      });
    }

    // Wait, then run fn, but only once the word is on screen.
    function wait(s, ms, fn) {
      setTimeout(function check() {
        if (s.visible && !document.hidden) fn();
        else setTimeout(check, 300);
      }, ms);
    }

    function start(s) {
      fit(s);
      if (document.fonts) document.fonts.ready.then(function () { fit(s); });
      window.addEventListener('resize', function () { fit(s); });
      var hold = +s.el.dataset.hold || DEFAULTS.hold;
      (function loop() {
        wait(s, hold, function () { swap(s, loop); });
      })();
    }

    function init() {
      var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var s = e.target._pws;
          s.visible = e.isIntersecting;
          if (s.visible && !s.started) { s.started = true; start(s); }
        });
      }, { threshold: 0.3 });
      document.querySelectorAll('[data-pixel-word-swap]').forEach(function (el) {
        if (el._pws) return;
        el._pws = setup(el);
        if (!reduced && el._pws.words.length > 1) io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<h1>
  Made of
  <span data-pixel-word-swap="pixels, bits, magic">pixels</span>
</h1>