Elastic Width Rotator
The changing word sits in a soft pill that stretches and springs to fit each new word, and the rest of the sentence reflows around it, even across lines. The old word slides up and out as the new one rises in.
Tools for teams who like to move fast.
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-elastic-width-rotator.
Options
Add any of these attributes to the same element.
data-hold | How long each word stays. Default 1800 ms. |
data-duration | Time for the box to settle. Default 1000 ms. |
data-bounce | How springy the box is, from 0 (no overshoot) to 1. Default 0.6. |
data-box | none drops the tinted pill and keeps just the springy width. |
The pill is tinted with the word’s own colour, so it matches whatever colour you give the word.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Elastic Width Rotator: the changing word sits in a soft pill that
// stretches and springs to the width of each new word, while the rest of
// the sentence reflows around it. The old word slides up and out as the new
// one slides in from below.
// Usage: <h1>Built for <span data-elastic-width-rotator="teams, solo makers, you">teams</span></h1>
// Optional: data-hold (ms per word, default 1800), data-duration (ms for the
// box to settle, default 1000), data-bounce (springiness from 0 to 1,
// default 0.6), data-box="none" (no tinted pill, just the moving words).
var DEFAULTS = { hold: 1800, duration: 1000, bounce: 0.6 };
var css =
'.ewr{display:inline-block;position:relative;box-sizing:content-box;white-space:nowrap;vertical-align:baseline;text-align:left;' +
'padding:0 .26em;margin:0 -.04em;border-radius:.26em;background:color-mix(in srgb,currentColor 13%,transparent);clip-path:inset(0 round .26em)}' +
'.ewr.ewr-plain{padding:0;margin:0;background:none}' +
'.ewr-word{display:inline-block}' +
'.ewr-old{position:absolute;left:.26em;top:0;white-space:nowrap}' +
'.ewr-plain .ewr-old{left:0}' +
'.ewr-probe{position:absolute;left:0;top:0;visibility:hidden;white-space:nowrap}' +
'.ewr-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-elastic-width-rotator') || el.textContent)
.split(',').map(function (w) { return w.trim(); }).filter(Boolean);
el.classList.add('ewr');
if (el.dataset.box === 'none') el.classList.add('ewr-plain');
el.textContent = '';
span('ewr-sr', el, words.join(', '));
var out = span('ewr-word', el, words[0]);
var probe = span('ewr-probe', el);
out.setAttribute('aria-hidden', 'true');
probe.setAttribute('aria-hidden', 'true');
return { el: el, words: words, out: out, probe: probe, i: 0, w: 0, busy: false, visible: false, started: false };
}
function measure(s, text) {
s.probe.textContent = text;
return s.probe.offsetWidth;
}
function fit(s) {
if (s.busy) return;
s.w = measure(s, s.words[s.i]);
s.el.style.width = s.w + 'px';
}
function swap(s, done) {
var d = s.el.dataset;
var t = +d.duration || DEFAULTS.duration;
var bounce = d.bounce !== undefined ? Math.max(0, Math.min(1, +d.bounce)) : DEFAULTS.bounce;
s.i = (s.i + 1) % s.words.length;
var to = s.words[s.i];
s.busy = true;
// The old word becomes a loose copy that slides up and away...
var old = span('ewr-old', s.el, s.out.textContent);
old.setAttribute('aria-hidden', 'true');
anime.animate(old, {
y: ['0em', '-1.2em'], opacity: [1, 0], duration: t * 0.4, ease: 'in(2)',
onComplete: function () { old.remove(); }
});
// ...while the new word rises into the box from below.
s.out.textContent = to;
anime.animate(s.out, { y: ['1.2em', '0em'], opacity: [0, 1], duration: t * 0.5, delay: t * 0.08, ease: 'out(3)' });
// The box springs to its new width; the sentence reflows as it goes.
var w1 = measure(s, to);
anime.animate(s, {
w: w1,
duration: t,
ease: bounce > 0 ? 'outElastic(1, ' + (1 - bounce * 0.75).toFixed(2) + ')' : 'out(4)',
onUpdate: function () { s.el.style.width = Math.max(0, s.w).toFixed(2) + 'px'; },
onComplete: function () { s.busy = false; done(); }
});
}
// 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) {
fit(s);
if (document.fonts) document.fonts.ready.then(function () { fit(s); });
window.addEventListener('resize', function () { fit(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._ewr;
s.visible = e.isIntersecting;
if (s.visible && !s.started) { s.started = true; start(s); }
});
}, { threshold: 0.3 });
document.querySelectorAll('[data-elastic-width-rotator]').forEach(function (el) {
if (el._ewr) return;
el._ewr = setup(el);
if (!reduced && el._ewr.words.length > 1) io.observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><h1>
Tools for
<span data-elastic-width-rotator="teams, solo makers, you">teams</span>
who like to move fast.
</h1>