← Text effects

Fuzzy Vibrate

The letters buzz in place, each one jittering and leaving faint ghosts either side, like a guitar string that’s just been plucked. Bring the pointer close, or touch them, and they shake much harder.

Feeling fuzzy

How to use it

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

Options

Add any of these attributes to the same element.

data-intensityHow much every letter shakes at rest, in em. Default 0.012; 0 keeps them still until the pointer comes near.
data-boostExtra shake right under the pointer, in em. Default 0.07.
data-radiusHow far the pointer’s pull reaches, in em. Default 1.8.
data-smearOne or two colours for the smear ghosts, separated by a comma. Default the text’s colour.
data-autoplayA pretend pointer sweeps along the text while no real one is moving. Handy for demos and touch screens.
1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Fuzzy Vibrate: the letters buzz in place, each one jittering and leaving
    // a faint smear behind, like a string that has just been plucked. Letters
    // near the pointer shake much harder.
    // Usage: <h1 data-fuzzy-vibrate>Buzzing</h1>
    // Optional: data-intensity (how much every letter shakes at rest, in em;
    // default 0.012), data-boost (extra shake right under the pointer, in em;
    // default 0.07), data-radius (how far the pointer's pull reaches, in em;
    // default 1.8), data-smear (one or two comma-separated colours for the
    // smear ghosts, e.g. "#ff3b6b,#22d3ee"; default the text colour),
    // data-autoplay (a pretend pointer sweeps along the text while no real one
    // is near; handy for demos and touch screens).
    var DEFAULTS = { intensity: 0.012, boost: 0.07, radius: 1.8 };

    var css =
      '.fzv-word{display:inline-block;white-space:nowrap}' +
      '.fzv-char{display:inline-block;will-change:transform,text-shadow}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    function split(el) {
      var text = el.textContent.replace(/\s+/g, ' ').trim();
      el.setAttribute('aria-label', text);
      el.textContent = '';
      var chars = [];
      text.split(' ').forEach(function (w, i) {
        if (i) el.appendChild(document.createTextNode(' '));
        var word = document.createElement('span');
        word.className = 'fzv-word';
        word.setAttribute('aria-hidden', 'true');
        Array.from(w).forEach(function (c) {
          var s = document.createElement('span');
          s.className = 'fzv-char';
          s.textContent = c;
          word.appendChild(s);
          chars.push({ el: s, f: 0, x: 0, y: 0 });
        });
        el.appendChild(word);
      });
      return chars;
    }

    function setup(el) {
      var d = el.dataset;
      var intensity = d.intensity !== undefined ? +d.intensity : DEFAULTS.intensity;
      var boost = d.boost !== undefined ? +d.boost : DEFAULTS.boost;
      var radiusEm = +d.radius || DEFAULTS.radius;
      var autoplay = d.autoplay !== undefined && d.autoplay !== 'false';
      var chars = split(el);
      var smear = (d.smear || '').split(',').map(function (c) { return c.trim(); }).filter(Boolean);
      var ghostA = smear[0] || 'currentColor';
      var ghostB = smear[1] || ghostA;
      if (reduced()) return;

      if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
      var fs = 16;
      // Letter centres relative to the element, from layout (ignoring the jitter).
      function measure() {
        fs = parseFloat(getComputedStyle(el).fontSize);
        chars.forEach(function (c) {
          var s = c.el;
          c.cx = s.offsetLeft + s.offsetWidth / 2;
          c.cy = s.offsetTop + s.offsetHeight / 2;
        });
      }
      measure();
      new ResizeObserver(measure).observe(el);
      if (document.fonts && document.fonts.ready) document.fonts.ready.then(measure);

      var pointer = null;        // in element coordinates, or null when away
      var lastReal = -1e9;
      var clock = 0;

      function onPointer(e) {
        var r = el.getBoundingClientRect();
        pointer = { x: e.clientX - r.left - el.clientLeft, y: e.clientY - r.top - el.clientTop };
        lastReal = performance.now();
      }
      window.addEventListener('pointermove', onPointer, { passive: true });
      window.addEventListener('pointerdown', onPointer, { passive: true });
      window.addEventListener('pointerup', function (e) { if (e.pointerType !== 'mouse') pointer = null; }, { passive: true });
      document.addEventListener('pointerleave', function () { pointer = null; });

      function mix(color, pct) {
        return 'color-mix(in srgb, ' + color + ' ' + pct + '%, transparent)';
      }

      var prev = 0, acc = 0;
      var ticker = anime.createTimer({
        loop: true,
        duration: 1000,
        autoplay: false,
        onUpdate: function () {
          var now = performance.now();
          var dt = prev ? Math.min(50, now - prev) : 16;
          prev = now;
          clock += dt;
          acc += dt;
          var p = pointer;
          if (autoplay && now - lastReal > 2000 && chars.length) {
            // Sweep back and forth along the line.
            var first = chars[0], last = chars[chars.length - 1];
            var k = (1 - Math.cos(clock * 0.0011)) / 2;
            p = { x: first.cx + (last.cx - first.cx) * k, y: first.cy + (last.cy - first.cy) * k };
          }
          var radius = radiusEm * fs;
          var jitter = acc >= 33;   // new shakes about 30 times a second
          if (jitter) acc = 0;
          chars.forEach(function (c) {
            var target = 0;
            if (p) {
              var dx = c.cx - p.x, dy = (c.cy - p.y) * 0.7;
              var dist = Math.sqrt(dx * dx + dy * dy);
              target = dist < radius ? (1 + Math.cos(Math.PI * dist / radius)) / 2 : 0;
            }
            c.f += (target - c.f) * (1 - Math.pow(0.85, dt / 16));
            if (!jitter) return;
            var amp = (intensity + boost * c.f) * fs;
            var nx = (Math.random() * 2 - 1) * amp;
            var ny = (Math.random() * 2 - 1) * amp * 0.45;
            var s = c.el.style;
            s.transform = 'translate(' + nx.toFixed(2) + 'px,' + ny.toFixed(2) + 'px)';
            // Ghosts trail either side of the shake direction.
            var g = Math.max(Math.abs(nx), fs * 0.006);
            var blur = (0.3 * g + c.f * fs * 0.02).toFixed(2);
            var alpha = Math.round(28 + 30 * c.f);
            s.textShadow =
              (-nx * 1.6).toFixed(2) + 'px ' + (-ny).toFixed(2) + 'px ' + blur + 'px ' + mix(ghostA, alpha) + ',' +
              (nx * 1.2 + (nx < 0 ? -g : g) * 0.6).toFixed(2) + 'px ' + (ny * 0.5).toFixed(2) + 'px ' + blur + 'px ' + mix(ghostB, Math.round(alpha * 0.7));
          });
        }
      });

      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { prev = 0; ticker.play(); }
        else ticker.pause();
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-fuzzy-vibrate]').forEach(function (el) {
        if (el._fzv) return;
        el._fzv = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<h1 data-fuzzy-vibrate>Feeling fuzzy</h1>

<!-- red and cyan smears, still until touched: -->
<h1 data-fuzzy-vibrate data-intensity="0" data-smear="#ff3b6b,#22d3ee">Feeling fuzzy</h1>