← Rotating words

Word Morph

One word melts into the next. As the old word blurs away and the new one sharpens, a threshold filter keeps their edges crisp, so the letters seem to flow like liquid from one shape into another.

Made to flow

How to use it

  1. Paste the snippet just before </head>.
  2. Wrap the changing word in a <span> and list every word, separated by commas, in data-word-morph.

Options

Add any of these attributes to the same element.

data-holdHow long each word stays. Default 1600 ms.
data-durationTime for each morph. Default 1100 ms.

Works best on big, bold, solid-coloured text: the filter turns soft edges hard, so gradients and very thin fonts lose detail mid-morph.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Word Morph: one word melts into the next. As the old word blurs away and
    // the new one sharpens, a threshold filter keeps their edges crisp, so the
    // letters look like liquid flowing from one shape into another.
    // Usage: <h1>Made to <span data-word-morph="flow, melt, morph">flow</span></h1>
    // Optional: data-hold (ms per word, default 1600), data-duration (ms per
    // morph, default 1100).
    var DEFAULTS = { hold: 1600, duration: 1100 };
    var FILTER = 'wmo-threshold';

    var css =
      '.wmo{display:inline-grid;vertical-align:baseline;text-align:left}' +
      // Only while melting: at rest the words keep their smooth edges.
      '.wmo-melting{filter:url(#' + FILTER + ')}' +
      '.wmo-word{grid-area:1/1;white-space:nowrap}' +
      '.wmo-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    // The trick: blur both words, then snap every pixel to either fully opaque
    // or fully clear. Where the two blurs overlap they fuse like droplets.
    function addFilter() {
      if (document.getElementById(FILTER)) return;
      var div = document.createElement('div');
      div.innerHTML =
        '<svg width="0" height="0" style="position:absolute" aria-hidden="true">' +
        '<filter id="' + FILTER + '" x="-20%" y="-40%" width="140%" height="180%">' +
        '<feColorMatrix in="SourceGraphic" type="matrix" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 255 -140"/>' +
        '</filter></svg>';
      document.body.appendChild(div.firstChild);
    }

    function build(el) {
      var words = (el.getAttribute('data-word-morph') || el.textContent)
        .split(',').map(function (w) { return w.trim(); }).filter(Boolean);
      el.classList.add('wmo');
      el.textContent = '';
      var sr = document.createElement('span');
      sr.className = 'wmo-sr';
      sr.textContent = words.join(', ');
      el.appendChild(sr);
      // Every word sits in the same grid cell, so the box is as wide as the
      // longest one and the sentence never shifts.
      var spans = words.map(function (w, i) {
        var s = document.createElement('span');
        s.className = 'wmo-word';
        s.textContent = w;
        s.setAttribute('aria-hidden', 'true');
        if (i) s.style.opacity = 0;
        el.appendChild(s);
        return s;
      });
      return { el: el, spans: spans, index: 0, mix: 0 };
    }

    // mix runs 0 → 1 as the old word gives way to the new one. The blur grows
    // very fast near the ends, so each word only becomes legible at the last
    // moment, as in the classic morphing-text effect.
    function paint(out, inn, mix) {
      var a = Math.max(mix, 0.001), b = Math.max(1 - mix, 0.001);
      inn.style.filter = 'blur(' + Math.min(8 / a - 8, 60).toFixed(2) + 'px)';
      inn.style.opacity = Math.pow(mix, 0.4).toFixed(3);
      out.style.filter = 'blur(' + Math.min(8 / b - 8, 60).toFixed(2) + 'px)';
      out.style.opacity = Math.pow(1 - mix, 0.4).toFixed(3);
    }

    function start(s) {
      var d = s.el.dataset;
      (function next() {
        var out = s.spans[s.index];
        s.index = (s.index + 1) % s.spans.length;
        var inn = s.spans[s.index];
        s.mix = 0;
        anime.animate(s, {
          mix: 1,
          duration: +d.duration || DEFAULTS.duration,
          delay: +d.hold || DEFAULTS.hold,
          ease: 'inOutSine',
          onUpdate: function () {
            s.el.classList.add('wmo-melting');
            paint(out, inn, s.mix);
          },
          onComplete: function () {
            s.el.classList.remove('wmo-melting');
            out.style.filter = inn.style.filter = '';
            next();
          }
        });
      })();
    }

    function init() {
      var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          if (!e.isIntersecting) return;
          io.unobserve(e.target);
          start(e.target._wmo);
        });
      }, { threshold: 0.3 });
      document.querySelectorAll('[data-word-morph]').forEach(function (el) {
        if (el._wmo) return;
        el._wmo = build(el);
        // Reduced motion: the first word simply stays.
        if (reduced || el._wmo.spans.length < 2) return;
        addFilter();
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<h1>
  Made to
  <span data-word-morph="flow, melt, morph">flow</span>
</h1>