← Typing

Typo Correction

Text types itself in, slips up, and fixes itself. A spell-check squiggle appears under the mistake, the cursor pauses, backspaces to the slip and types the rest correctly. Give it your own typo, or let it make a natural one.

Nobody's perfect

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-typo-correction to a heading. It types the text with a typo in it, then corrects it.

Options

Add any of these attributes to the same element.

data-typoThe misspelt version to type first. Without it, two letters in the longest word get swapped.
data-speedTyping time per character. Default 75 ms.
data-loopClear and type it all again after a pause.
data-holdHow long the finished text stays before looping. Default 2600 ms.

It notices the slip a few letters later, as a person would, and only retypes from there.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Typo Correction: text types itself in, slips up, and fixes itself. A few
    // letters after the mistake a spell-check squiggle appears, the cursor
    // backspaces to the slip and types the rest correctly.
    // Usage: <h1 data-typo-correction>Pixel perfect</h1>  (makes its own typo)
    //    or: <h1 data-typo-correction data-typo="Pixel perfcet">Pixel perfect</h1>
    // Optional: data-typo (the misspelt version to type first), data-speed (ms
    // per character, default 75), data-loop (clear and type again after a
    // pause), data-hold (ms before looping, default 2600).
    var DEFAULTS = { speed: 75, hold: 2600 };

    var css =
      '.tyc{white-space:pre-wrap}' +
      '.tyc-bad{text-decoration:underline wavy transparent;text-decoration-skip-ink:none;text-underline-offset:0.12em;text-decoration-thickness:0.05em;transition:text-decoration-color .2s}' +
      '.tyc-bad.is-flagged{text-decoration-color:#ef4444}' +
      '.tyc-cursor{display:inline-block;width:0.07em;height:0.95em;margin-left:0.04em;vertical-align:-0.1em;background:currentColor;border-radius:1px}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    // No typo given: swap two neighbouring letters in the longest word, the
    // most natural slip there is.
    function makeTypo(text) {
      var best = -1, bestLen = 0;
      text.replace(/[A-Za-z]{3,}/g, function (w, at) {
        if (w.length >= bestLen) { best = at; bestLen = w.length; }
      });
      if (best < 0) return text;
      var i = best + Math.max(1, Math.floor(bestLen / 2) - 1);
      if (text[i] === text[i + 1]) i++;
      return text.slice(0, i) + text[i + 1] + text[i] + text.slice(i + 2);
    }

    function setup(el) {
      var text = el.textContent.replace(/\s+/g, ' ').trim();
      var typo = el.dataset.typo || makeTypo(text);
      el.setAttribute('aria-label', text);
      el.classList.add('tyc');
      el.textContent = '';
      var good = document.createElement('span');
      var bad = document.createElement('span');
      var cursor = document.createElement('span');
      bad.className = 'tyc-bad';
      cursor.className = 'tyc-cursor';
      [good, bad, cursor].forEach(function (s) { s.setAttribute('aria-hidden', 'true'); el.appendChild(s); });
      good.textContent = text;
      // Where the typo first goes wrong.
      var at = 0;
      while (at < text.length && text[at] === typo[at]) at++;
      return { el: el, text: text, typo: typo, at: at, good: good, bad: bad, cursor: cursor };
    }

    function run(t) {
      var d = t.el.dataset;
      var speed = +d.speed || DEFAULTS.speed;
      var loop = d.loop !== undefined && d.loop !== 'false';
      // You notice a slip two or three letters later (or at the end of the word).
      var stop = Math.min(t.typo.length, t.at + 3);
      var blink = null;
      var wait = function (ms, fn) { setTimeout(fn, ms); };

      function blinkOn() {
        if (!blink) blink = anime.animate(t.cursor, { opacity: [1, 0], duration: 480, ease: 'inOutQuad', loop: true, alternate: true });
      }
      function blinkOff() {
        if (blink) { blink.revert(); blink = null; }
      }
      // A little human unevenness: pauses after spaces and punctuation.
      function gap(ch) {
        return /[ ,.!?]/.test(ch || '') ? speed * 2.4 : speed * anime.utils.random(0.6, 1.4, 2);
      }

      function typeTypo(i) {
        blinkOff();
        t.good.textContent = t.typo.slice(0, Math.min(i, t.at));
        t.bad.textContent = t.typo.slice(t.at, i);
        if (i < stop) return wait(gap(t.typo[i - 1]), function () { typeTypo(i + 1); });
        // Oops: the squiggle appears, a beat of hesitation, then backspace.
        blinkOn();
        wait(260, function () { t.bad.classList.add('is-flagged'); });
        wait(1100, function () { fix(stop - t.at); });
      }
      function fix(n) {
        blinkOff();
        t.bad.textContent = t.bad.textContent.slice(0, n);
        if (n > 0) return wait(speed * 0.7, function () { fix(n - 1); });
        t.bad.classList.remove('is-flagged');
        wait(speed * 3, function () { typeRest(t.at + 1); });
      }
      function typeRest(i) {
        blinkOff();
        t.good.textContent = t.text.slice(0, i);
        if (i < t.text.length) return wait(gap(t.text[i - 1]), function () { typeRest(i + 1); });
        blinkOn();
        if (loop) wait(+d.hold || DEFAULTS.hold, again);
      }
      // Looping: the finished line fades away and typing starts over.
      function again() {
        anime.animate(t.good, { opacity: 0, duration: 350, ease: 'in(2)' }).then(function () {
          t.good.style.opacity = '';
          start();
        });
      }
      function start() {
        t.good.textContent = '';
        wait(500, function () { typeTypo(1); });
      }
      blinkOn();
      start();
    }

    function init() {
      var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          if (!e.isIntersecting) return;
          io.unobserve(e.target);
          run(e.target._tyc);
        });
      }, { threshold: 0.3 });
      document.querySelectorAll('[data-typo-correction]').forEach(function (el) {
        if (el._tyc) return;
        el._tyc = setup(el);
        // Reduced motion: the correct text, no cursor.
        if (reduced) { el._tyc.cursor.style.display = 'none'; return; }
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<h1 data-typo-correction>Pixel perfect</h1>

<!-- your own typo: -->
<h1 data-typo-correction data-typo="Nobody's prefect">Nobody's perfect</h1>