← Rotating words

Underline Wipe Swap

The word in your sentence sits on a bold underline. The line sweeps away, the word changes while it’s gone, and the line sweeps back in under the new word.

Write clearly

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-underline-wipe-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 1100 ms.
data-colorThe underline’s colour. Default the word’s.
data-thicknessThe underline’s thickness, in em. Default 0.08.
1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Underline Wipe Swap: the word in your sentence sits on an underline. The
    // underline sweeps away, the word changes as the line is gone, and the
    // underline sweeps back in beneath the new word.
    // Usage: <h1>Write <span data-underline-wipe-swap="clearly, boldly, freely">clearly</span></h1>
    // Optional: data-hold (ms per word, default 1800), data-duration (ms per
    // swap, default 1100), data-color (the line, default the word's colour),
    // data-thickness (the line, in em, default 0.08).
    var DEFAULTS = { hold: 1800, duration: 1100, thickness: 0.08 };

    var css =
      '.uws{display:inline-block;position:relative;white-space:nowrap;vertical-align:baseline;text-align:left}' +
      '.uws-word{display:inline-block}' +
      '.uws-line{position:absolute;left:0;right:0;bottom:-.1em;background:currentColor;border-radius:1em;transform-origin:0 50%;pointer-events:none}' +
      '.uws-probe{position:absolute;left:0;top:0;visibility:hidden;white-space:nowrap}' +
      '.uws-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 setup(el) {
      var words = (el.getAttribute('data-underline-wipe-swap') || el.textContent)
        .split(',').map(function (w) { return w.trim(); }).filter(Boolean);
      var d = el.dataset;
      el.classList.add('uws');
      el.textContent = '';
      span('uws-sr', el, words.join(', '));
      var out = span('uws-word', el, words[0]);
      var line = span('uws-line', el);
      var probe = span('uws-probe', el);
      [out, line, probe].forEach(function (n) { n.setAttribute('aria-hidden', 'true'); });
      line.style.height = (+d.thickness || DEFAULTS.thickness) + 'em';
      if (d.color) line.style.background = d.color;
      return { el: el, words: words, out: out, line: line, 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 swap(s, done) {
      var t = +s.el.dataset.duration || DEFAULTS.duration;
      s.i = (s.i + 1) % s.words.length;
      var to = s.words[s.i];
      s.busy = true;
      // 1. The line sweeps out to the right, and the word sinks and fades a
      //    little behind it.
      anime.utils.set(s.line, { transformOrigin: '100% 50%' });
      anime.animate(s.line, { scaleX: [1, 0], duration: t * 0.38, ease: 'inOut(3)' });
      anime.animate(s.out, {
        opacity: [1, 0], y: ['0em', '0.18em'], duration: t * 0.3, delay: t * 0.1, ease: 'in(2)',
        onComplete: function () {
          // 2. With the line gone the word changes and the box resizes.
          s.out.textContent = to;
          anime.animate(s, {
            w: measure(s, to), duration: t * 0.3, ease: 'inOut(2)',
            onUpdate: function () { s.el.style.width = s.w.toFixed(2) + 'px'; }
          });
          anime.animate(s.out, { opacity: [0, 1], y: ['-0.18em', '0em'], duration: t * 0.35, ease: 'out(3)' });
          // 3. The line sweeps back in from the left under the new word.
          anime.utils.set(s.line, { transformOrigin: '0% 50%' });
          anime.animate(s.line, {
            scaleX: [0, 1], duration: t * 0.42, delay: t * 0.18, ease: 'out(4)',
            onComplete: function () { s.busy = false; done(); }
          });
        }
      });
    }

    // 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._uws;
          s.visible = e.isIntersecting;
          if (s.visible && !s.started) { s.started = true; start(s); }
        });
      }, { threshold: 0.3 });
      document.querySelectorAll('[data-underline-wipe-swap]').forEach(function (el) {
        if (el._uws) return;
        el._uws = setup(el);
        if (!reduced && el._uws.words.length > 1) io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<h1>
  Write
  <span data-underline-wipe-swap="clearly, boldly, freely">clearly</span>
</h1>