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
- Paste the snippet just before
</head>. - Add
data-typo-correctionto 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-typo | The misspelt version to type first. Without it, two letters in the longest word get swapped. |
data-speed | Typing time per character. Default 75 ms. |
data-loop | Clear and type it all again after a pause. |
data-hold | How 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.
<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><h1 data-typo-correction>Pixel perfect</h1>
<!-- your own typo: -->
<h1 data-typo-correction data-typo="Nobody's prefect">Nobody's perfect</h1>