← Text reveals

Spray Paint Reveal

A spray can sweeps back and forth across the text. Paint lands solid only inside the letters, as if sprayed through a stencil, and leaves a soft speckled overspray around them.

Fresh paint

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-spray-paint-reveal to a short, one-line heading.

Options

Add any of these attributes to the same element.

data-colorThe paint. Default the text’s own colour.
data-passesHow many sweeps across the text. Default 2.
data-oversprayHow strong the mist around the letters is, from 0 (none) to 1. Default 0.5.
data-durationTime for all the sweeps. Default 1600 ms.
data-loopFade it off and spray it again.
data-holdHow long it stays, when looping. Default 1800 ms.
data-delayWait before starting. Default 0 ms.

The paint is drawn on a canvas over the real text, which stays in the page (invisibly) for screen readers and copying. Keep it to one line: the text doesn’t wrap.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Spray Paint Reveal: a spray can sweeps across the text, and the paint
    // only lands solid inside the letters, as if sprayed through a stencil,
    // with a soft speckled overspray left around them.
    // Usage: <h1 data-spray-paint-reveal>Fresh paint</h1>
    // Keep it to one line: the text does not wrap.
    // Optional: data-color (the paint, default the text colour), data-passes
    // (sweeps across the text, default 2), data-overspray (how strong the
    // mist around the letters is, 0 to 1, default 0.5), data-duration (ms for
    // all the sweeps, default 1600), data-loop (fade it off and spray again),
    // data-hold (ms to stay, when looping, default 1800), data-delay (ms).
    var DEFAULTS = { passes: 2, overspray: 0.5, duration: 1600, hold: 1800, delay: 0 };

    var css =
      '.sprv{position:relative;display:inline-block;white-space:nowrap}' +
      '.sprv-txt{color:transparent}' +
      '.sprv-cv{position:absolute;pointer-events:none}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    function layer() { return document.createElement('canvas'); }

    // A rough bell curve, so most droplets land near the nozzle.
    function gauss() { return (Math.random() + Math.random() + Math.random() - 1.5) / 1.5; }

    function setup(el) {
      var text = el.textContent.replace(/\s+/g, ' ').trim();
      el.textContent = '';
      el.classList.add('sprv');
      // The real text stays (invisible) for layout, selection and screen readers.
      var txt = document.createElement('span');
      txt.className = 'sprv-txt';
      txt.textContent = text;
      el.appendChild(txt);
      var cv = layer();
      cv.className = 'sprv-cv';
      cv.setAttribute('aria-hidden', 'true');
      el.appendChild(cv);
      var s = { el: el, txt: txt, cv: cv, ctx: cv.getContext('2d'), mask: layer(), ink: layer(), tmp: layer(), text: text, t: 0, done: false };
      size(s);
      return s;
    }

    // Size the canvases to the text plus a margin for overspray, and draw the
    // stencil: the text itself, in the element's own font.
    function size(s) {
      var el = s.el, cs = getComputedStyle(el);
      var fs = parseFloat(cs.fontSize);
      var pad = Math.round(fs * 0.3);
      var w = s.txt.offsetWidth + pad * 2, h = s.txt.offsetHeight + pad * 2;
      var dpr = Math.min(2, window.devicePixelRatio || 1);
      s.cv.style.left = s.cv.style.top = -pad + 'px';
      s.cv.style.width = w + 'px';
      s.cv.style.height = h + 'px';
      [s.cv, s.mask, s.ink, s.tmp].forEach(function (c) {
        c.width = Math.round(w * dpr);
        c.height = Math.round(h * dpr);
        c.getContext('2d').setTransform(dpr, 0, 0, dpr, 0, 0);
      });
      s.w = w; s.h = h; s.pad = pad; s.fs = fs;
      s.color = s.el.dataset.color || cs.color;
      var m = s.mask.getContext('2d');
      m.font = cs.fontStyle + ' ' + cs.fontWeight + ' ' + fs + 'px ' + cs.fontFamily;
      if ('letterSpacing' in m) m.letterSpacing = cs.letterSpacing === 'normal' ? '0px' : cs.letterSpacing;
      m.textBaseline = 'alphabetic';
      m.fillStyle = '#000';
      var mt = m.measureText(s.text);
      var a = mt.fontBoundingBoxAscent || fs * 0.9, d = mt.fontBoundingBoxDescent || fs * 0.25;
      m.fillText(s.text, pad, pad + (s.txt.offsetHeight - a - d) / 2 + a);
    }

    // Spray droplets around the nozzle at (x, y) onto the ink layer.
    function spray(s, x, y, r, n) {
      var c = s.ink.getContext('2d');
      c.fillStyle = s.color;
      for (var i = 0; i < n; i++) {
        var dx = gauss() * r, dy = gauss() * r;
        var dot = Math.random() * Math.random() * s.fs * 0.035 + 0.5;
        c.globalAlpha = 0.25 + Math.random() * 0.6;
        c.beginPath();
        c.arc(x + dx, y + dy, dot, 0, 6.2832);
        c.fill();
      }
      c.globalAlpha = 1;
    }

    // Show the ink: faint everywhere (overspray), full strength through the
    // stencil. dry (0 to 1) lays a solid coat over the letters at the end.
    function render(s, dry) {
      var ctx = s.ctx, t = s.tmp.getContext('2d'), w = s.w, h = s.h;
      var mist = +s.el.dataset.overspray;
      if (isNaN(mist)) mist = DEFAULTS.overspray;
      t.globalCompositeOperation = 'source-over';
      t.clearRect(0, 0, w, h);
      t.drawImage(s.mask, 0, 0, w, h);
      t.globalCompositeOperation = 'source-in';
      t.drawImage(s.ink, 0, 0, w, h);
      if (dry > 0) {
        t.globalAlpha = dry;
        t.fillStyle = s.color;
        t.fillRect(0, 0, w, h);
        t.globalAlpha = 1;
      }
      ctx.clearRect(0, 0, w, h);
      ctx.globalAlpha = 0.28 * mist;
      ctx.drawImage(s.ink, 0, 0, w, h);
      ctx.globalAlpha = 1;
      ctx.drawImage(s.tmp, 0, 0, w, h);
    }

    function finish(s) {
      s.done = true;
      render(s, 1);
    }

    function play(el) {
      var s = el._sprv, d = el.dataset;
      var loop = d.loop !== undefined && d.loop !== 'false';
      var duration = +d.duration || DEFAULTS.duration;
      var passes = Math.max(1, Math.round(+d.passes || DEFAULTS.passes));
      s.done = false;
      s.ink.getContext('2d').clearRect(0, 0, s.w, s.h);
      s.cv.style.opacity = 1;
      var start = null, last = 0;
      function frame(now) {
        if (!s.visible) { s.resume = frame; start = null; return; }
        if (start === null) start = now - last * duration;
        var p = Math.min(1, (now - start) / duration);
        // Each pass sweeps the full width, alternating direction, on its own
        // band of the text's height, with a wobbly hand.
        var steps = Math.max(1, Math.ceil((p - last) * 60));
        var band = (s.h - s.pad * 2) / passes;
        var r = band * 0.5 + s.fs * 0.06;
        for (var k = 1; k <= steps; k++) {
          var q = last + (p - last) * k / steps;
          var pass = Math.min(passes - 1, Math.floor(q * passes));
          var u = q * passes - pass;
          if (pass % 2) u = 1 - u;
          var x = s.pad * 0.3 + u * (s.w - s.pad * 0.6);
          var y = s.pad + band * (pass + 0.5) + Math.sin(q * 40) * band * 0.18;
          spray(s, x, y, r, Math.round(s.fs * 1.6));
        }
        last = p;
        render(s, 0);
        if (p < 1) { requestAnimationFrame(frame); return; }
        // The paint dries to a solid coat inside the stencil.
        var o = { dry: 0 };
        anime.animate(o, {
          dry: 1,
          duration: 450,
          ease: 'out(2)',
          onUpdate: function () { render(s, o.dry); },
          onComplete: function () {
            finish(s);
            if (!loop) return;
            setTimeout(function () {
              anime.animate(s.cv, {
                opacity: 0,
                duration: 500,
                ease: 'inQuad',
                onComplete: function () { setTimeout(function () { play(el); }, 300); }
              });
            }, +d.hold || DEFAULTS.hold);
          }
        });
      }
      requestAnimationFrame(frame);
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var el = e.target, s = el._sprv;
          s.visible = e.isIntersecting;
          if (!s.visible) return;
          if (!s.started) {
            s.started = true;
            setTimeout(function () { play(el); }, +el.dataset.delay || DEFAULTS.delay);
          } else if (s.resume) {
            var f = s.resume;
            s.resume = null;
            requestAnimationFrame(f);
          }
        });
      }, { threshold: 0.3 });
      var ro = new ResizeObserver(function (entries) {
        entries.forEach(function (e) {
          var s = e.target._sprv;
          if (!s) return;
          size(s);
          // A finished stencil is redrawn crisp; one mid-spray starts afresh.
          if (s.done || reduced()) finish(s);
        });
      });
      var run = function () {
        document.querySelectorAll('[data-spray-paint-reveal]').forEach(function (el) {
          if (el._sprv) return;
          var s = el._sprv = setup(el);
          ro.observe(el);
          if (reduced()) { finish(s); return; }
          io.observe(el);
        });
      };
      // The stencil is drawn in the element's font, so wait for web fonts.
      if (document.fonts && document.fonts.ready) document.fonts.ready.then(run);
      else run();
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<h1 data-spray-paint-reveal>Fresh paint</h1>

<!-- or, pink, three passes, with heavy overspray: -->
<h1 data-spray-paint-reveal data-color="#f472b6" data-passes="3" data-overspray="1">Fresh paint</h1>