← Text effects

Glitch Text

Every few seconds the text breaks up for a split second: red and cyan slices jump sideways out of it and the line judders, like a corrupted video signal. Then it snaps back as if nothing happened.

Signal lost

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-glitch-text to any heading or text element.

Options

Add any of these attributes to the same element.

data-triggerview (default) glitches every few seconds while on screen. hover glitches when the pointer arrives.
data-intervalTime between glitches. Default 2400 ms.
data-durationHow long each glitch lasts. Default 500 ms.
data-strengthHow far the slices jump. Default 1; try 2 for a harsher glitch.
data-colorsThe two split colours, comma separated. Default #ff2d6f, #00c8ff.

Short, bold, uppercase words glitch best. Screen readers get the text once, without the coloured copies.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Glitch Text: every so often the text breaks up for a moment, with red and
    // cyan slices jumping sideways out of it, like a corrupted video signal.
    // Keeps going while the text is on screen.
    // Usage: <h1 data-glitch-text>System error</h1>
    // Optional: data-trigger ("view", the default, glitches every few seconds
    // while on screen; "hover" glitches when the pointer arrives), data-interval
    // (ms between glitches; default 2400), data-duration (ms each glitch lasts;
    // default 500), data-strength (how far slices jump; default 1), data-colors
    // (the two split colours, comma separated; default "#ff2d6f, #00c8ff").
    var DEFAULTS = { interval: 2400, duration: 500, strength: 1, colors: '#ff2d6f, #00c8ff' };
    var FRAME = 50; // ms each jumbled frame is shown for

    var css =
      '.gli-box{position:relative;display:inline-block}' +
      '.gli-layer{position:absolute;inset:0;opacity:0;pointer-events:none;white-space:inherit;user-select:none}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    // Wraps the text in an inline box and lays two coloured copies over it.
    function build(el) {
      var d = el.dataset;
      var colors = (d.colors || DEFAULTS.colors).split(',');
      var box = document.createElement('span');
      box.className = 'gli-box';
      while (el.firstChild) box.appendChild(el.firstChild);
      el.appendChild(box);
      var text = box.textContent;
      var layers = colors.slice(0, 2).map(function (c) {
        var layer = document.createElement('span');
        layer.className = 'gli-layer';
        layer.setAttribute('aria-hidden', 'true');
        layer.textContent = text;
        layer.style.color = c.trim();
        box.appendChild(layer);
        return layer;
      });
      return { box: box, layers: layers };
    }

    // A random horizontal slice of the text, as a clip-path.
    function slice() {
      var top = anime.utils.random(0, 85);
      var height = anime.utils.random(8, 38);
      return 'inset(' + top + '% -10% ' + Math.max(0, 100 - top - height) + '% -10%)';
    }

    function glitch(el, parts) {
      var d = el.dataset;
      var px = parseFloat(getComputedStyle(el).fontSize) * 0.12 * (+d.strength || DEFAULTS.strength);
      var frames = Math.max(2, Math.round((+d.duration || DEFAULTS.duration) / FRAME));
      var tl = anime.createTimeline({ autoplay: false });
      for (var i = 0; i < frames; i++) {
        var t = i * FRAME;
        // Every few frames the slices drop out, so it stutters instead of buzzing.
        var off = i % 4 === 2;
        parts.layers.forEach(function (layer, n) {
          var dir = n ? 1 : -1;
          tl.set(layer, {
            opacity: off ? 0 : 1,
            clipPath: slice(),
            x: dir * anime.utils.random(px * 0.3, px, 1)
          }, t);
        });
        tl.set(parts.box, {
          x: anime.utils.random(-px, px, 1) * 0.35,
          skewX: Math.random() < 0.25 ? anime.utils.random(-10, 10) : 0
        }, t);
      }
      tl.set(parts.layers, { opacity: 0, x: 0 }, frames * FRAME)
        .set(parts.box, { x: 0, skewX: 0 }, frames * FRAME);
      tl.play();
      return tl;
    }

    function init() {
      if (reduced()) return;
      document.querySelectorAll('[data-glitch-text]').forEach(function (el) {
        if (el._gli) return;
        var parts = (el._gli = build(el));
        var d = el.dataset;
        var busy = false;
        function once() {
          if (busy) return;
          busy = true;
          glitch(el, parts).then(function () { busy = false; });
        }
        if (d.trigger === 'hover') {
          el.addEventListener('pointerenter', once);
          return;
        }
        // Only glitch while visible, and start with one as it scrolls in.
        var timer = null;
        var interval = +d.interval || DEFAULTS.interval;
        new IntersectionObserver(function (entries) {
          clearInterval(timer);
          if (!entries[0].isIntersecting) return;
          setTimeout(once, 300);
          timer = setInterval(function () {
            once();
            // Sometimes a second, quick aftershock.
            if (Math.random() < 0.4) setTimeout(once, (+d.duration || DEFAULTS.duration) + 150);
          }, interval);
        }, { threshold: 0.3 }).observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<h1 data-glitch-text>Signal lost</h1>

<!-- on hover, e.g. a nav link: -->
<a href="/play" data-glitch-text data-trigger="hover">Play</a>