← Text reveals

Dissolve-in Text

The text appears through a grainy noise dissolve, speck by speck, with a thin fringe of coloured grain running just ahead of the ink.

Out of the noise

How to use it

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

Options

Add any of these attributes to the same element.

data-colorThe leading grain’s colour. Default #a855f7; none for no fringe.
data-grainSize of the grain, in px. Default 2.
data-durationTime to dissolve in. Default 1600 ms.
data-loopDissolve in, hold, dissolve away and in again.
data-holdHow long the text stays before dissolving away, when looping. Default 1800 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 () {
    // Dissolve-in Text: text appears through a grainy noise dissolve, speck by
    // speck, with a thin fringe of coloured grain running just ahead of the ink.
    // Usage: <h1 data-dissolve-in-text>Out of the noise</h1>
    // Optional: data-color (the leading grain, default #a855f7; "none" for no
    // fringe), data-grain (grain size in px, default 2), data-duration (ms,
    // default 1600), data-loop (dissolve away, then in again), data-hold (ms to
    // stay before dissolving away), data-delay (ms before starting).
    var DEFAULTS = { color: '#a855f7', grain: 2, duration: 1600, hold: 1800, delay: 0 };

    var NS = 'http://www.w3.org/2000/svg';
    var count = 0;
    // How hard the noise is thresholded (higher = crisper specks), and how far
    // the coloured fringe runs ahead.
    var SHARP = 28, FRINGE = 0.09;

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

    // Noise, thresholded at a moving level, decides which specks of the text
    // show. A second, slightly lower threshold paints the fringe.
    function filter(el) {
      var d = el.dataset;
      var id = 'dit-filter-' + (++count);
      var color = d.color || DEFAULTS.color;
      var fringe = color !== 'none';
      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="-5%" y="-10%" width="110%" height="120%" color-interpolation-filters="sRGB">' +
        '<feTurbulence type="fractalNoise" baseFrequency="' + (1 / (+d.grain || DEFAULTS.grain)).toFixed(3) +
        '" numOctaves="2" seed="' + (count * 7) + '" result="noise"/>' +
        '<feColorMatrix in="noise" type="matrix" values="0 0 0 0 0  0 0 0 0 0  0 0 0 0 0  1 0 0 0 0" result="n"/>' +
        '<feComponentTransfer in="n" result="m1"><feFuncA type="linear" slope="' + SHARP + '" intercept="-99"/></feComponentTransfer>' +
        '<feComposite in="SourceGraphic" in2="m1" operator="in" result="ink"/>' +
        (fringe
          ? '<feComponentTransfer in="n" result="m2"><feFuncA type="linear" slope="' + SHARP + '" intercept="-99"/></feComponentTransfer>' +
            '<feFlood flood-color="' + color + '"/>' +
            '<feComposite in2="m2" operator="in"/>' +
            '<feComposite in2="SourceAlpha" operator="in" result="edge"/>' +
            '<feMerge><feMergeNode in="edge"/><feMergeNode in="ink"/></feMerge>'
          : '') +
        '</filter>';
      el.appendChild(svg);
      var funcs = svg.querySelectorAll('feFuncA');
      return { id: id, ink: funcs[0], edge: funcs[1] };
    }

    // `t` is the threshold: specks whose noise is above it are visible.
    function level(f, t) {
      f.ink.setAttribute('intercept', (-SHARP * t).toFixed(3));
      if (f.edge) f.edge.setAttribute('intercept', (-SHARP * (t - FRINGE)).toFixed(3));
    }

    function run(el, out, done) {
      var s = el._dit, d = el.dataset;
      var state = { t: out ? 0.1 : 0.82 };
      level(s, state.t);
      el.style.filter = 'url(#' + s.id + ')';
      el.style.opacity = '';
      anime.animate(state, {
        t: out ? 0.82 : 0.1,
        duration: (+d.duration || DEFAULTS.duration) * (out ? 0.7 : 1),
        ease: out ? 'in(1.5)' : 'inOut(1.4)',
        onUpdate: function () { level(s, state.t); },
        onComplete: function () {
          if (out) el.style.opacity = 0;
          else el.style.filter = '';
          if (done) done();
        }
      });
    }

    function play(el) {
      var d = el.dataset;
      var loop = d.loop !== undefined && d.loop !== 'false';
      run(el, false, function () {
        if (!loop) return;
        setTimeout(function () {
          run(el, true, function () { setTimeout(function () { play(el); }, 400); });
        }, +d.hold || DEFAULTS.hold);
      });
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          if (!e.isIntersecting) return;
          io.unobserve(e.target);
          var el = e.target;
          setTimeout(function () { play(el); }, +el.dataset.delay || DEFAULTS.delay);
        });
      }, { threshold: 0.3 });
      document.querySelectorAll('[data-dissolve-in-text]').forEach(function (el) {
        if (el._dit) return;
        el._dit = true;
        if (reduced()) return;
        el._dit = filter(el);
        el.style.opacity = 0;
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<h1 data-dissolve-in-text>Out of the noise</h1>

<!-- or, coarser grain with no coloured fringe: -->
<h1 data-dissolve-in-text data-grain="4" data-color="none">Out of the noise</h1>