← Rotating words

Vapour Word Swap

The word in your sentence evaporates: its letters swell, blur and drift upwards like steam, each curling off its own way, while the next word condenses out of a haze below and sharpens into place.

Ideas that linger

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-vapour-word-swap.

Options

Add any of these attributes to the same element.

data-holdHow long each word stays. Default 1800 ms.
data-durationTime for each swap. Default 1400 ms.
data-blurHow blurry the vapour gets, in px. Default 12.
data-driftHow far the vapour rises, in em. Default 0.6.

The vapour rises above the line, so leave a little space above the headline.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Vapour Word Swap: the word in your sentence evaporates, its letters
    // swelling, blurring and drifting upwards like steam, while the next word
    // condenses out of a haze in its place.
    // Usage: <h1>Ideas that <span data-vapour-word-swap="linger, drift, rise">linger</span></h1>
    // Optional: data-hold (ms per word, default 1800), data-duration (ms per
    // swap, default 1400), data-blur (px, default 12), data-drift (how far the
    // vapour rises, in em, default 0.6).
    var DEFAULTS = { hold: 1800, duration: 1400, blur: 12, drift: 0.6 };

    var css =
      '.vws{display:inline-block;position:relative;white-space:nowrap;vertical-align:baseline;text-align:left}' +
      '.vws-word{white-space:nowrap}' +
      '.vws-old{position:absolute;left:0;top:0}' +
      '.vws-ch{display:inline-block;white-space:pre;will-change:transform,opacity,filter}' +
      '.vws-probe{position:absolute;left:0;top:0;visibility:hidden;white-space:nowrap}' +
      '.vws-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);

    function span(cls, parent, text) {
      var s = document.createElement('span');
      s.className = cls;
      if (text) s.textContent = text;
      parent.appendChild(s);
      return s;
    }

    function word(parent, text, before) {
      var w = document.createElement('span');
      w.className = 'vws-word';
      w.setAttribute('aria-hidden', 'true');
      text.split('').forEach(function (c) { span('vws-ch', w, c); });
      parent.insertBefore(w, before || null);
      return w;
    }

    function setup(el) {
      var words = (el.getAttribute('data-vapour-word-swap') || el.textContent)
        .split(',').map(function (w) { return w.trim(); }).filter(Boolean);
      el.classList.add('vws');
      el.textContent = '';
      span('vws-sr', el, words.join(', '));
      var out = word(el, words[0]);
      var probe = span('vws-probe', el);
      probe.setAttribute('aria-hidden', 'true');
      return { el: el, words: words, out: out, probe: probe, i: 0, w: 0, busy: false, visible: false, started: false };
    }

    function measure(s, text) {
      s.probe.textContent = text;
      return s.probe.offsetWidth;
    }

    function fit(s) {
      if (s.busy) return;
      s.w = measure(s, s.words[s.i]);
      s.el.style.width = s.w + 'px';
    }

    function rnd(a, b) { return a + Math.random() * (b - a); }

    function swap(s, done) {
      var d = s.el.dataset;
      var t = +d.duration || DEFAULTS.duration;
      var blur = d.blur !== undefined ? +d.blur : DEFAULTS.blur;
      var drift = d.drift !== undefined ? +d.drift : DEFAULTS.drift;
      s.i = (s.i + 1) % s.words.length;
      var to = s.words[s.i];
      s.busy = true;

      // The old word lifts out of the flow and evaporates, letter by letter
      // in a loose random order, each wisp curling off to one side.
      var old = s.out;
      old.classList.add('vws-old');
      anime.animate(old.querySelectorAll('.vws-ch'), {
        y: function () { return '-' + (drift * rnd(0.6, 1.3)).toFixed(3) + 'em'; },
        x: function () { return rnd(-0.25, 0.25).toFixed(3) + 'em'; },
        rotate: function () { return rnd(-14, 14); },
        scale: function () { return rnd(1.3, 1.8); },
        filter: ['blur(0px)', 'blur(' + blur + 'px)'],
        opacity: [1, 0],
        duration: t * 0.6,
        delay: function () { return rnd(0, t * 0.25); },
        ease: 'out(2)',
        onComplete: function () { old.remove(); }
      });

      // The new word condenses from a low haze: blurred, swollen and faint
      // letters settle and sharpen into place.
      s.out = word(s.el, to, s.probe);
      var chars = s.out.querySelectorAll('.vws-ch');
      Array.prototype.forEach.call(chars, function (c) {
        anime.utils.set(c, {
          y: (drift * rnd(0.3, 0.6)).toFixed(3) + 'em',
          scale: rnd(1.3, 1.7),
          filter: 'blur(' + blur + 'px)',
          opacity: 0
        });
      });
      anime.animate(chars, {
        y: '0em', scale: 1, filter: 'blur(0px)', opacity: 1,
        duration: t * 0.6,
        delay: function () { return t * 0.3 + rnd(0, t * 0.15); },
        ease: 'out(3)'
      });

      anime.animate(s, {
        w: measure(s, to), duration: t * 0.6, delay: t * 0.2, ease: 'inOut(3)',
        onUpdate: function () { s.el.style.width = s.w.toFixed(2) + 'px'; }
      });
      setTimeout(function () { s.busy = false; done(); }, t * 1.05);
    }

    // Wait, then run fn, but only once the word is on screen.
    function wait(s, ms, fn) {
      setTimeout(function check() {
        if (s.visible && !document.hidden) fn();
        else setTimeout(check, 300);
      }, ms);
    }

    function start(s) {
      fit(s);
      if (document.fonts) document.fonts.ready.then(function () { fit(s); });
      window.addEventListener('resize', function () { fit(s); });
      var hold = +s.el.dataset.hold || DEFAULTS.hold;
      (function loop() {
        wait(s, hold, function () { swap(s, loop); });
      })();
    }

    function init() {
      var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var s = e.target._vws;
          s.visible = e.isIntersecting;
          if (s.visible && !s.started) { s.started = true; start(s); }
        });
      }, { threshold: 0.3 });
      document.querySelectorAll('[data-vapour-word-swap]').forEach(function (el) {
        if (el._vws) return;
        el._vws = setup(el);
        if (!reduced && el._vws.words.length > 1) io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<h1>
  Ideas that
  <span data-vapour-word-swap="linger, drift, rise">linger</span>
</h1>