← Rotating words

Letter Cascade Swap

The old word’s letters drop away one by one, tipping as they fall, while the new word’s letters rise up into place behind them with a little bounce.

Stay curious

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-letter-cascade-swap.

Options

Add any of these attributes to the same element.

data-holdHow long each word stays. Default 1800 ms.
data-durationHow long each letter takes to fall or rise. Default 550 ms.
data-staggerGap between letters. Default 45 ms.
1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Letter Cascade Swap: the old word's letters drop away one by one, tipping
    // as they fall, while the new word's letters rise up into place behind
    // them. The word's box eases to the new width as the cascade runs.
    // Usage: <h1>Stay <span data-letter-cascade-swap="curious, hungry, humble">curious</span></h1>
    // Optional: data-hold (ms per word, default 1800), data-duration (ms per
    // letter, default 550), data-stagger (ms between letters, default 45).
    var DEFAULTS = { hold: 1800, duration: 550, stagger: 45 };

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

    // A word as a row of letter spans.
    function word(cls, parent, text) {
      var w = span(cls, parent);
      w.setAttribute('aria-hidden', 'true');
      text.split('').forEach(function (c) { span('lcs-ch', w, c); });
      return w;
    }

    function setup(el) {
      var words = (el.getAttribute('data-letter-cascade-swap') || el.textContent)
        .split(',').map(function (w) { return w.trim(); }).filter(Boolean);
      el.classList.add('lcs');
      el.textContent = '';
      span('lcs-sr', el, words.join(', '));
      var out = word('lcs-word', el, words[0]);
      var probe = span('lcs-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 swap(s, done) {
      var d = s.el.dataset;
      var dur = +d.duration || DEFAULTS.duration;
      var stagger = +d.stagger || DEFAULTS.stagger;
      s.i = (s.i + 1) % s.words.length;
      var to = s.words[s.i];
      s.busy = true;

      // The current word lifts out of the flow so the new one can take its place.
      var old = s.out;
      old.className = 'lcs-word lcs-old';
      var oldChars = old.querySelectorAll('.lcs-ch');
      anime.animate(oldChars, {
        y: ['0em', '0.75em'],
        rotate: function () { return anime.utils.random(-28, 28); },
        opacity: [1, 0],
        duration: dur,
        delay: anime.stagger(stagger),
        ease: 'in(3)',
        onComplete: function () { old.remove(); }
      });

      s.out = word('lcs-word', s.el, to);
      s.el.insertBefore(s.out, s.probe);
      var newChars = s.out.querySelectorAll('.lcs-ch');
      anime.utils.set(newChars, { opacity: 0 });
      anime.animate(newChars, {
        y: ['0.6em', '0em'],
        opacity: [0, 1],
        duration: dur,
        delay: anime.stagger(stagger, { start: dur * 0.45 }),
        ease: 'outBack(1.6)'
      });

      var longest = Math.max(oldChars.length, newChars.length);
      anime.animate(s, {
        w: measure(s, to),
        duration: dur * 0.45 + stagger * longest + dur * 0.4,
        ease: 'inOut(3)',
        onUpdate: function () { s.el.style.width = s.w.toFixed(2) + 'px'; },
        onComplete: function () {
          s.busy = false;
          // Let the last letters land before the hold starts.
          setTimeout(done, dur * 0.6);
        }
      });
    }

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

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<h1>
  Stay
  <span data-letter-cascade-swap="curious, hungry, humble">curious</span>
</h1>