← Rotating words

Select and Retype

A caret drags across the word in your sentence, highlighting it like selected text. A beat later the next word is typed straight over the selection, letter by letter, with a blinking caret.

We write code

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-select-and-retype.

Options

Add any of these attributes to the same element.

data-holdHow long each word stays. Default 1800 ms.
data-speedTyping time per letter (it varies a little, like a real typist). Default 85 ms.
data-select-speedTime per letter while the selection drags across. Default 45 ms; 0 selects the whole word at once, like a double-click.
data-select-colorThe selection highlight. Default a soft system blue.
data-caret-colorThe caret’s colour. Default the word’s.

The word takes its natural width, so the sentence closes up and opens out as it’s retyped, just as it would in an editor. Typed Word Rotator backspaces instead.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Select and Retype: the word in your sentence gets selected like
    // highlighted text, as if someone dragged across it, then the next word is
    // typed over the selection with a blinking caret. The sentence reflows
    // around the word as it changes length.
    // Usage: <h1>We write <span data-select-and-retype="code, copy, stories">code</span></h1>
    // Optional: data-hold (ms per word, default 1800), data-speed (ms per typed
    // letter, default 85), data-select-speed (ms per letter while selecting,
    // default 45; 0 selects the whole word at once, like a double-click),
    // data-select-color (default a soft system blue), data-caret-color
    // (default the word's colour).
    var DEFAULTS = { hold: 1800, speed: 85, selectSpeed: 45, selectColor: 'rgba(56,132,255,.3)' };

    var css =
      '.sar{white-space:nowrap}' +
      '.sar-sel{border-radius:.06em;box-shadow:0 0 0 .02em transparent}' +
      '.sar-caret{display:inline-block;position:relative;width:0;height:0}' +
      '.sar-caret::after{content:"";position:absolute;left:-.02em;bottom:-.14em;height:1em;border-left:.06em solid currentColor}' +
      '.sar-blink .sar-caret::after{animation:sar-blink 1s steps(1) infinite}' +
      '@keyframes sar-blink{50%{opacity:0}}' +
      '.sar-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-select-and-retype') || el.textContent)
        .split(',').map(function (w) { return w.trim(); }).filter(Boolean);
      var d = el.dataset;
      el.classList.add('sar', 'sar-blink');
      el.textContent = '';
      span('sar-sr', el, words.join(', '));
      var vis = span('sar-vis', el);
      vis.setAttribute('aria-hidden', 'true');
      var sel = span('sar-sel', vis);
      var caret = span('sar-caret', vis);
      var rest = span('sar-rest', vis, words[0]);
      sel.style.background = d.selectColor || DEFAULTS.selectColor;
      if (d.caretColor) caret.style.color = d.caretColor;
      caret.style.opacity = '0';
      return { el: el, words: words, sel: sel, caret: caret, rest: rest, i: 0, visible: false, started: false };
    }

    // Draw the word as [selected part][caret][rest].
    function draw(s, selected, rest) {
      s.sel.textContent = selected;
      s.rest.textContent = rest;
    }

    // Run each(0..n), ms apart (ms may be a function, for a human rhythm), then done.
    function steps(n, ms, each, done) {
      var k = 0;
      (function tick() {
        if (k > n) return done();
        each(k++);
        setTimeout(tick, typeof ms === 'function' ? ms() : ms);
      })();
    }

    function swap(s, done) {
      var d = s.el.dataset;
      var speed = +d.speed || DEFAULTS.speed;
      var selSpeed = d.selectSpeed !== undefined ? +d.selectSpeed : DEFAULTS.selectSpeed;
      var from = s.words[s.i];
      s.i = (s.i + 1) % s.words.length;
      var to = s.words[s.i];

      // 1. The caret lands at the start and drags across the word.
      s.el.classList.remove('sar-blink');
      s.caret.style.opacity = '1';
      draw(s, '', from);
      var drag = selSpeed > 0 ? from.length : 1;
      setTimeout(function () {
        steps(drag, selSpeed || 0, function (k) {
          var n = selSpeed > 0 ? k : (k ? from.length : 0);
          draw(s, from.slice(0, n), from.slice(n));
        }, function () {
          // 2. A beat with the word selected, then typing replaces it.
          s.el.classList.add('sar-blink');
          setTimeout(function () {
            s.el.classList.remove('sar-blink');
            steps(to.length - 1, function () { return speed * (0.6 + Math.random() * 0.8); }, function (k) {
              typed(s, to.slice(0, k + 1));
            }, function () {
              // 3. The caret blinks a moment, then fades away.
              s.el.classList.add('sar-blink');
              setTimeout(function () {
                s.caret.style.opacity = '0';
                done();
              }, 900);
            });
          }, 450);
        });
      }, 250);
    }

    // Typed text sits before the caret, unselected.
    function typed(s, text) {
      s.sel.style.background = 'none';
      s.sel.textContent = text;
      s.rest.textContent = '';
    }

    function resetSel(s) {
      s.sel.style.background = s.el.dataset.selectColor || DEFAULTS.selectColor;
    }

    // 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) {
      var hold = +s.el.dataset.hold || DEFAULTS.hold;
      (function loop() {
        wait(s, hold, function () {
          // The caret is gone and the word is plain text again: move it back
          // after the caret so the next drag starts from the left.
          var word = s.sel.textContent + s.rest.textContent;
          resetSel(s);
          draw(s, '', word);
          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._sar;
          s.visible = e.isIntersecting;
          if (s.visible && !s.started) { s.started = true; start(s); }
        });
      }, { threshold: 0.3 });
      document.querySelectorAll('[data-select-and-retype]').forEach(function (el) {
        if (el._sar) return;
        el._sar = setup(el);
        if (!reduced && el._sar.words.length > 1) io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<h1>
  We write
  <span data-select-and-retype="code, copy, stories">code</span>
</h1>