Shared-letter Swap
When the word changes, the letters both words share hop across to their new spots, while the ones that aren’t needed drift away and the missing ones fade in. Anagrams look like magic.
Time to react
How to use it
- Paste the snippet just before
</head>. - Wrap the changing word in a
<span>and list every word, separated by commas, indata-shared-letter-swap. Words with lots of letters in common work best.
Options
Add any of these attributes to the same element.
data-hold | How long each word stays. Default 1800 ms. |
data-duration | Time for each swap. Default 1000 ms. |
data-hop | How high moving letters arc, in em. Default 0.18; 0 slides them flat. |
data-case | ignore lets a capital and a small letter count as the same. |
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Shared-letter Swap: when the word changes, letters the old and new words
// share glide (with a little hop) to their new spots, while the letters
// that aren't shared fade out and in. Anagrams look especially magic.
// Usage: <h1>Time to <span data-shared-letter-swap="react, create, trace">react</span></h1>
// Optional: data-hold (ms per word, default 1800), data-duration (ms per
// swap, default 1000), data-hop (how high moving letters arc, in em,
// default 0.18; 0 slides them flat), data-case ("ignore" lets "R" match "r").
var DEFAULTS = { hold: 1800, duration: 1000, hop: 0.18 };
var css =
'.shl{display:inline-block;position:relative;white-space:nowrap;vertical-align:baseline;text-align:left}' +
'.shl-size{visibility:hidden;white-space:pre}' +
'.shl-ch{position:absolute;top:0;left:0;white-space:pre;will-change:transform,opacity}' +
'.shl-probe{position:absolute;left:0;top:0;visibility:hidden;white-space:pre}' +
'.shl-probe>span{display:inline}' +
'.shl-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-shared-letter-swap') || el.textContent)
.split(',').map(function (w) { return w.trim(); }).filter(Boolean);
el.classList.add('shl');
el.textContent = '';
span('shl-sr', el, words.join(', '));
// An invisible copy of the word holds the line height and baseline.
var size = span('shl-size', el, words[0]);
var probe = span('shl-probe', el);
size.setAttribute('aria-hidden', 'true');
probe.setAttribute('aria-hidden', 'true');
var s = { el: el, words: words, size: size, probe: probe, chars: [], i: 0, w: 0, busy: false, visible: false, started: false };
place(s);
if (document.fonts) document.fonts.ready.then(function () { place(s); });
return s;
}
// Lay the word out in the probe and read each letter's x and the width.
function layout(s, text) {
s.probe.textContent = '';
var spans = text.split('').map(function (c) { return span('', s.probe, c); });
return { xs: spans.map(function (n) { return n.offsetLeft; }), w: s.probe.offsetWidth };
}
// Draw the current word as absolutely placed letters.
function place(s) {
if (s.busy) return;
var word = s.words[s.i];
var l = layout(s, word);
s.chars.forEach(function (c) { c.remove(); });
s.chars = word.split('').map(function (c, k) {
var n = span('shl-ch', s.el, c);
n.setAttribute('aria-hidden', 'true');
n._shlC = c;
n._shlX = l.xs[k];
anime.utils.set(n, { x: l.xs[k], y: '0em', opacity: 1 });
return n;
});
s.size.textContent = word;
s.w = l.w;
s.el.style.width = l.w + 'px';
}
function swap(s, done) {
var d = s.el.dataset;
var t = +d.duration || DEFAULTS.duration;
var hop = d.hop !== undefined ? +d.hop : DEFAULTS.hop;
var fold = d.case === 'ignore' ? function (c) { return c.toLowerCase(); } : function (c) { return c; };
s.i = (s.i + 1) % s.words.length;
var to = s.words[s.i];
var l = layout(s, to);
var old = s.chars;
var oldX = old.map(function (n) { return n._shlX; });
var used = [];
s.busy = true;
// Pair each new letter with the nearest unused old copy of it.
var next = to.split('').map(function (c, k) {
var best = -1, bestD = Infinity;
if (c !== ' ') old.forEach(function (n, j) {
if (used[j] || fold(n._shlC) !== fold(c)) return;
var dist = Math.abs(oldX[j] - l.xs[k]);
if (dist < bestD) { bestD = dist; best = j; }
});
if (best >= 0) {
used[best] = true;
var n = old[best];
n.textContent = c;
n._shlC = c;
var moved = Math.abs(oldX[best] - l.xs[k]) > 1;
anime.animate(n, {
x: l.xs[k],
y: moved && hop ? ['0em', -hop * (0.6 + 0.4 * Math.min(1, bestD / 120)) + 'em', '0em'] : '0em',
duration: t * 0.75, delay: t * 0.15, ease: 'inOut(3)'
});
return n;
}
// A letter the old word didn't have fades in from below.
var m = span('shl-ch', s.el, c);
m.setAttribute('aria-hidden', 'true');
m._shlC = c;
anime.utils.set(m, { x: l.xs[k], opacity: 0 });
anime.animate(m, { y: ['0.35em', '0em'], opacity: [0, 1], scale: [0.6, 1], duration: t * 0.5, delay: t * 0.5, ease: 'out(3)' });
return m;
});
// Letters the new word doesn't need drift up and fade.
old.forEach(function (n, j) {
if (used[j]) return;
anime.animate(n, { y: ['0em', '-0.35em'], opacity: 0, scale: 0.6, duration: t * 0.4, ease: 'in(2)', onComplete: function () { n.remove(); } });
});
next.forEach(function (n, k) { n._shlX = l.xs[k]; });
s.chars = next;
s.size.textContent = to;
s.el.style.width = s.w + 'px';
anime.animate(s, {
w: l.w, duration: t * 0.75, delay: t * 0.15, ease: 'inOut(3)',
onUpdate: function () { s.el.style.width = s.w.toFixed(2) + 'px'; },
onComplete: function () { s.busy = false; setTimeout(done, t * 0.1); }
});
}
// 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) {
window.addEventListener('resize', function () { place(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._shl;
s.visible = e.isIntersecting;
if (s.visible && !s.started) { s.started = true; start(s); }
});
}, { threshold: 0.3 });
document.querySelectorAll('[data-shared-letter-swap]').forEach(function (el) {
if (el._shl) return;
el._shl = setup(el);
if (!reduced && el._shl.words.length > 1) io.observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><h1>
Time to
<span data-shared-letter-swap="react, create, trace">react</span>
</h1>