← Text reveals

Ink Spread Reveal

Text bleeds in like ink soaking into paper. Each letter lands as a soft, swollen blot and draws in to a crisp edge, spreading out from one spot, while the edges stay feathery until the ink dries.

Written in ink

How to use it

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

Options

Add any of these attributes to the same element.

data-fromWhere the ink lands first: center (default), first, last or a letter number.
data-loopSoak in, hold, fade and soak in again.
data-holdHow long the text stays before fading, when looping. Default 1800 ms.
data-durationHow long each letter takes to soak in. Default 1500 ms.
data-staggerGap between letters. Default 45 ms.
data-delayWait before starting. Default 0 ms.
1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Ink Spread Reveal: text bleeds in like ink soaking into paper. Each letter
    // starts as a soft, swollen blot and draws in to a crisp edge, spreading out
    // from one point, while the edges stay feathered and fibrous until it dries.
    // Usage: <h1 data-ink-spread-reveal>Written in ink</h1>
    // Optional: data-from (where the ink lands first: "center", the default,
    // "first", "last" or a letter number), data-loop (fade, then soak in again),
    // data-hold (ms to stay before fading), data-duration (ms per letter),
    // data-stagger (ms between letters), data-delay (ms before starting).
    var DEFAULTS = { from: 'center', duration: 1500, stagger: 45, hold: 1800, delay: 0 };

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

    var count = 0;
    var NS = 'http://www.w3.org/2000/svg';

    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 (word, i) {
        if (i) el.appendChild(document.createTextNode(' '));
        var w = document.createElement('span');
        w.className = 'isr-word';
        w.setAttribute('aria-hidden', 'true');
        Array.from(word).forEach(function (c) {
          var s = document.createElement('span');
          s.className = 'isr-char';
          s.textContent = c;
          w.appendChild(s);
          chars.push(s);
        });
        el.appendChild(w);
      });
      return chars;
    }

    // An SVG filter that roughens the edges with paper-fibre noise. Its
    // strength is turned down to nothing as the ink dries.
    function filter(el) {
      var id = 'isr-filter-' + (++count);
      var svg = document.createElementNS(NS, 'svg');
      svg.setAttribute('aria-hidden', 'true');
      svg.setAttribute('width', '0');
      svg.setAttribute('height', '0');
      svg.style.position = 'absolute';
      svg.innerHTML =
        '<filter id="' + id + '" x="-10%" y="-20%" width="120%" height="140%">' +
        '<feTurbulence type="fractalNoise" baseFrequency="0.035 0.06" numOctaves="3" seed="' + count + '" result="n"/>' +
        '<feDisplacementMap in="SourceGraphic" in2="n" scale="0" xChannelSelector="R" yChannelSelector="G"/>' +
        '</filter>';
      el.appendChild(svg);
      return { id: id, map: svg.querySelector('feDisplacementMap') };
    }

    function play(el) {
      var d = el.dataset;
      var s = el._isr;
      var loop = d.loop !== undefined && d.loop !== 'false';
      var duration = +d.duration || DEFAULTS.duration;
      var stagger = +d.stagger || DEFAULTS.stagger;
      var from = d.from || DEFAULTS.from;
      if (/^\d+$/.test(from)) from = Math.max(0, +from - 1);
      var fs = parseFloat(getComputedStyle(el).fontSize);
      var ink = { wet: 1 };
      var total = duration + stagger * s.chars.length;

      function wetness() {
        s.f.map.setAttribute('scale', (ink.wet * fs * 0.3).toFixed(2));
      }

      el.style.filter = 'url(#' + s.f.id + ')';
      var tl = anime.createTimeline({
        delay: +d.delay || DEFAULTS.delay,
        onComplete: function () {
          el.style.filter = '';
          if (loop) setTimeout(function () { fadeOut(); }, +d.hold || DEFAULTS.hold);
        }
      });
      tl.add(s.chars, {
        opacity: [0, 1],
        scale: [1.35, 1],
        filter: ['blur(' + (fs * 0.16).toFixed(1) + 'px)', 'blur(0px)'],
        duration: duration,
        ease: 'out(3)',
        delay: anime.stagger(stagger, { from: from })
      }, 0);
      tl.add(ink, { wet: [1, 0], duration: total, ease: 'out(2)', onUpdate: wetness }, 0);

      function fadeOut() {
        anime.animate(s.chars, {
          opacity: 0,
          filter: 'blur(' + (fs * 0.1).toFixed(1) + 'px)',
          duration: 700,
          ease: 'in(2)',
          onComplete: function () { setTimeout(function () { play(el); }, 300); }
        });
      }
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          if (!e.isIntersecting) return;
          io.unobserve(e.target);
          play(e.target);
        });
      }, { threshold: 0.2 });
      document.querySelectorAll('[data-ink-spread-reveal]').forEach(function (el) {
        if (el._isr) return;
        var chars = split(el);
        el._isr = { chars: chars };
        if (reduced()) return;
        el._isr.f = filter(el);
        chars.forEach(function (c) { c.style.opacity = 0; });
        io.observe(el);
      });
    }

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

<!-- or, spreading from the first letter, looping: -->
<h1 data-ink-spread-reveal data-from="first" data-loop>Written in ink</h1>