← Rotating words

Glitch Word Swap

The word in your sentence glitches into the next one. Red and cyan copies split off, slices of the word jump sideways, and the letters flicker between old and new until the new word locks in.

Hack the planet

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-glitch-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 glitch. Default 650 ms.
data-colorsTwo colours for the split copies, separated by a comma. Default #ff2e63, #08d9d6.
data-intensityHow far the copies and slices jump, in em. Default 0.08.
1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Glitch Word Swap: the word in your sentence glitches into the next one.
    // Red and cyan copies split off, slices of the word jump sideways, and the
    // letters flicker between the old word and the new until the new one wins.
    // Usage: <h1>Hack the <span data-glitch-word-swap="planet, system, future">planet</span></h1>
    // Optional: data-hold (ms per word, default 1800), data-duration (ms per
    // glitch, default 650), data-colors (two colours for the split copies,
    // default "#ff2e63, #08d9d6"), data-intensity (how far slices jump, in em,
    // default 0.08).
    var DEFAULTS = { hold: 1800, duration: 650, colors: '#ff2e63, #08d9d6', intensity: 0.08 };

    var css =
      '.gws{display:inline-block;position:relative;white-space:nowrap;vertical-align:baseline;text-align:left}' +
      '.gws-word{position:relative;z-index:1;display:inline-block}' +
      '.gws-ghost{position:absolute;z-index:0;left:0;top:0;white-space:nowrap;pointer-events:none;opacity:0}' +
      '.gws-on .gws-ghost{opacity:.9}' +
      '.gws-probe{position:absolute;left:0;top:0;visibility:hidden;white-space:nowrap}' +
      '.gws-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-glitch-word-swap') || el.textContent)
        .split(',').map(function (w) { return w.trim(); }).filter(Boolean);
      var colors = (el.dataset.colors || DEFAULTS.colors).split(',').map(function (c) { return c.trim(); });
      el.classList.add('gws');
      el.textContent = '';
      span('gws-sr', el, words.join(', '));
      var out = span('gws-word', el, words[0]);
      var a = span('gws-ghost', el, words[0]);
      var b = span('gws-ghost', el, words[0]);
      el.insertBefore(a, out);
      el.insertBefore(b, out);
      a.style.color = colors[0];
      b.style.color = colors[1] || colors[0];
      var probe = span('gws-probe', el);
      [out, a, b, probe].forEach(function (n) { n.setAttribute('aria-hidden', 'true'); });
      return { el: el, words: words, out: out, a: a, b: b, probe: probe, 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';
    }

    function rnd(a, b) { return a + Math.random() * (b - a); }

    // A random horizontal band of the word, as a clip-path.
    function slice() {
      var top = rnd(0, 80);
      return 'inset(' + top.toFixed(1) + '% -10% ' + Math.max(0, 100 - top - rnd(8, 40)).toFixed(1) + '% -10%)';
    }

    // Mix the two words letter by letter: each letter is the new one with
    // probability p, so the word flickers and settles.
    function mix(from, to, p) {
      var len = Math.max(from.length, to.length), outText = '';
      for (var i = 0; i < len; i++) outText += Math.random() < p ? (to[i] || '') : (from[i] || '');
      return outText;
    }

    function swap(s, done) {
      var d = s.el.dataset;
      var amp = +d.intensity || DEFAULTS.intensity;
      var from = s.words[s.i];
      s.i = (s.i + 1) % s.words.length;
      var to = s.words[s.i];
      var w0 = s.w, w1 = measure(s, to);
      var state = { p: 0 }, last = -1;
      s.busy = true;
      s.el.classList.add('gws-on');
      anime.animate(state, {
        p: 1,
        duration: +d.duration || DEFAULTS.duration,
        ease: 'linear',
        onUpdate: function () {
          var p = state.p;
          s.w = w0 + (w1 - w0) * (p < 0.5 ? 2 * p * p : 1 - Math.pow(-2 * p + 2, 2) / 2);
          s.el.style.width = s.w.toFixed(2) + 'px';
          // Jump to a new glitch frame roughly every 45 ms.
          var frame = Math.floor(p * (+d.duration || DEFAULTS.duration) / 45);
          if (frame === last) return;
          last = frame;
          // Strongest in the middle, calm at both ends.
          var k = Math.sin(Math.PI * p);
          var text = mix(from, to, Math.min(1, Math.max(0, (p - 0.2) / 0.6)));
          if (p > 0.85) text = to;
          s.out.textContent = text;
          s.a.textContent = Math.random() < 0.5 ? from : to;
          s.b.textContent = text;
          s.out.style.transform = 'translateX(' + (rnd(-1, 1) * amp * k * 0.6).toFixed(3) + 'em)';
          s.out.style.clipPath = Math.random() < 0.3 * k ? slice() : '';
          s.a.style.transform = 'translate(' + (-amp * k * rnd(0.5, 1.6)).toFixed(3) + 'em,' + (rnd(-0.3, 0.3) * amp).toFixed(3) + 'em)';
          s.b.style.transform = 'translate(' + (amp * k * rnd(0.5, 1.6)).toFixed(3) + 'em,' + (rnd(-0.3, 0.3) * amp).toFixed(3) + 'em)';
          s.a.style.clipPath = slice();
          s.b.style.clipPath = slice();
        },
        onComplete: function () {
          s.out.textContent = to;
          s.a.textContent = s.b.textContent = to;
          s.out.style.transform = s.out.style.clipPath = '';
          s.el.classList.remove('gws-on');
          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._gws;
          s.visible = e.isIntersecting;
          if (s.visible && !s.started) { s.started = true; start(s); }
        });
      }, { threshold: 0.3 });
      document.querySelectorAll('[data-glitch-word-swap]').forEach(function (el) {
        if (el._gws) return;
        el._gws = setup(el);
        if (!reduced && el._gws.words.length > 1) io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<h1>
  Hack the
  <span data-glitch-word-swap="planet, system, future">planet</span>
</h1>