← Text reveals

Rapid Word Sequence

A sentence plays one big word at a time, each one cut in with a quick punch, like a fast-cut lyric video. Longer words and punctuation stay on screen a beat longer.

Say it loud, say it fast, make it stick.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-rapid-word-sequence to a heading with a short sentence.

Options

Add any of these attributes to the same element.

data-speedHow long each word stays, before extra time for longer words and punctuation. Default 240 ms.
data-colorThe highlight colour some words flash in. Default #a855f7.
data-loopPlay the sentence again after a pause. Without it, it stops on the last word.
data-holdThe pause between plays, when looping. Default 900 ms.
data-delayWait before starting. Default 0 ms.

Screen readers hear the whole sentence at once. Words that are too wide shrink to fit.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Rapid Word Sequence: a sentence plays one big word at a time, each cut
    // in with a quick punch, like a fast-cut lyric video. Longer words and
    // punctuation stay on screen a beat longer.
    // Usage: <h1 data-rapid-word-sequence>Say it loud, say it fast, make it stick.</h1>
    // Optional: data-speed (ms each word stays, before extra time for its
    // length and punctuation, default 240), data-color (the highlight colour
    // some words flash in, default "#a855f7"), data-loop (play the sentence
    // again after a pause), data-hold (ms to pause between plays, default
    // 900), data-delay (ms). Without data-loop it stops on the last word.
    var DEFAULTS = { speed: 240, color: '#a855f7', hold: 900, delay: 0 };
    // How each word arrives: a mix of hard cuts and quick moves.
    var CUTS = [
      { scale: [1.45, 1], duration: 150 },
      { opacity: [1, 1], duration: 1 },
      { y: ['0.4em', 0], duration: 130 },
      { scale: [0.55, 1], duration: 140 },
      { rotate: [-9, 0], scale: [1.2, 1], duration: 150 },
      { y: ['-0.35em', 0], duration: 130 },
      { opacity: [1, 1], duration: 1 },
      { x: ['0.5em', 0], duration: 120 }
    ];
    // Which words flash in the highlight colour.
    var ACCENT = [0, 0, 1, 0, 0, 1, 0, 1, 0, 0, 0, 1];

    var css =
      '.rws-box{display:grid;place-items:center;height:1.3em;max-width:100%;overflow:visible}' +
      '.rws-w{display:inline-block;white-space:nowrap;line-height:1.1;will-change:transform}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    function setup(el) {
      var text = el.textContent.replace(/\s+/g, ' ').trim();
      el.setAttribute('aria-label', text);
      el.textContent = '';
      var box = document.createElement('span');
      box.className = 'rws-box';
      box.setAttribute('aria-hidden', 'true');
      var w = document.createElement('span');
      w.className = 'rws-w';
      box.appendChild(w);
      el.appendChild(box);
      return { box: box, w: w, words: text.split(' '), i: 0, visible: false, timer: 0 };
    }

    function show(el, s, k) {
      var d = el.dataset;
      var word = s.words[k];
      s.w.textContent = word;
      s.w.style.color = ACCENT[k % ACCENT.length] ? (d.color || DEFAULTS.color) : '';
      // Shrink a word that would not fit across the box.
      s.w.style.fontSize = '';
      var room = s.box.clientWidth, need = s.w.offsetWidth;
      if (need > room) s.w.style.fontSize = (room / need).toFixed(3) + 'em';
      var cut = CUTS[(k * 5 + s.i) % CUTS.length];
      var props = { ease: 'out(3)' };
      Object.keys(cut).forEach(function (p) { props[p] = cut[p]; });
      anime.utils.set(s.w, { x: 0, y: 0, scale: 1, rotate: 0, opacity: 1 });
      anime.animate(s.w, props);
      var speed = +d.speed || DEFAULTS.speed;
      var extra = word.length * 18 + (/[,;:–—]$/.test(word) ? 220 : 0) + (/[.!?]$/.test(word) ? 420 : 0);
      return speed + extra;
    }

    function tick(el) {
      var s = el._rws, d = el.dataset;
      var loop = d.loop !== undefined && d.loop !== 'false';
      // Paused off screen: pick up again when it scrolls back in.
      if (!s.visible) { s.waiting = true; return; }
      if (s.k >= s.words.length) {
        if (!loop) return;
        s.k = 0;
        s.i++;
        anime.animate(s.w, { opacity: 0, scale: 0.9, duration: 160, ease: 'in(2)' });
        s.timer = setTimeout(function () { tick(el); }, +d.hold || DEFAULTS.hold);
        return;
      }
      var wait = show(el, s, s.k++);
      s.timer = setTimeout(function () { tick(el); }, wait);
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var el = e.target, s = el._rws;
          s.visible = e.isIntersecting;
          if (!s.visible) return;
          if (!s.started) {
            s.started = true;
            s.k = 0;
            setTimeout(function () { tick(el); }, +el.dataset.delay || DEFAULTS.delay);
          } else if (s.waiting) {
            s.waiting = false;
            tick(el);
          }
        });
      }, { threshold: 0.2 });
      document.querySelectorAll('[data-rapid-word-sequence]').forEach(function (el) {
        if (el._rws) return;
        if (reduced()) { el._rws = true; return; }
        el._rws = setup(el);
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<h1 data-rapid-word-sequence>Say it loud, say it fast, make it stick.</h1>

<!-- or, slower and looping: -->
<h1 data-rapid-word-sequence data-speed="400" data-loop>Say it loud, say it fast, make it stick.</h1>