Underline Wipe Swap
The word in your sentence sits on a bold underline. The line sweeps away, the word changes while it’s gone, and the line sweeps back in under the new word.
Write clearly
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-underline-wipe-swap.
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 1100 ms. |
data-color | The underline’s colour. Default the word’s. |
data-thickness | The underline’s thickness, in em. Default 0.08. |
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Underline Wipe Swap: the word in your sentence sits on an underline. The
// underline sweeps away, the word changes as the line is gone, and the
// underline sweeps back in beneath the new word.
// Usage: <h1>Write <span data-underline-wipe-swap="clearly, boldly, freely">clearly</span></h1>
// Optional: data-hold (ms per word, default 1800), data-duration (ms per
// swap, default 1100), data-color (the line, default the word's colour),
// data-thickness (the line, in em, default 0.08).
var DEFAULTS = { hold: 1800, duration: 1100, thickness: 0.08 };
var css =
'.uws{display:inline-block;position:relative;white-space:nowrap;vertical-align:baseline;text-align:left}' +
'.uws-word{display:inline-block}' +
'.uws-line{position:absolute;left:0;right:0;bottom:-.1em;background:currentColor;border-radius:1em;transform-origin:0 50%;pointer-events:none}' +
'.uws-probe{position:absolute;left:0;top:0;visibility:hidden;white-space:nowrap}' +
'.uws-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-underline-wipe-swap') || el.textContent)
.split(',').map(function (w) { return w.trim(); }).filter(Boolean);
var d = el.dataset;
el.classList.add('uws');
el.textContent = '';
span('uws-sr', el, words.join(', '));
var out = span('uws-word', el, words[0]);
var line = span('uws-line', el);
var probe = span('uws-probe', el);
[out, line, probe].forEach(function (n) { n.setAttribute('aria-hidden', 'true'); });
line.style.height = (+d.thickness || DEFAULTS.thickness) + 'em';
if (d.color) line.style.background = d.color;
return { el: el, words: words, out: out, line: line, 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 t = +s.el.dataset.duration || DEFAULTS.duration;
s.i = (s.i + 1) % s.words.length;
var to = s.words[s.i];
s.busy = true;
// 1. The line sweeps out to the right, and the word sinks and fades a
// little behind it.
anime.utils.set(s.line, { transformOrigin: '100% 50%' });
anime.animate(s.line, { scaleX: [1, 0], duration: t * 0.38, ease: 'inOut(3)' });
anime.animate(s.out, {
opacity: [1, 0], y: ['0em', '0.18em'], duration: t * 0.3, delay: t * 0.1, ease: 'in(2)',
onComplete: function () {
// 2. With the line gone the word changes and the box resizes.
s.out.textContent = to;
anime.animate(s, {
w: measure(s, to), duration: t * 0.3, ease: 'inOut(2)',
onUpdate: function () { s.el.style.width = s.w.toFixed(2) + 'px'; }
});
anime.animate(s.out, { opacity: [0, 1], y: ['-0.18em', '0em'], duration: t * 0.35, ease: 'out(3)' });
// 3. The line sweeps back in from the left under the new word.
anime.utils.set(s.line, { transformOrigin: '0% 50%' });
anime.animate(s.line, {
scaleX: [0, 1], duration: t * 0.42, delay: t * 0.18, ease: 'out(4)',
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._uws;
s.visible = e.isIntersecting;
if (s.visible && !s.started) { s.started = true; start(s); }
});
}, { threshold: 0.3 });
document.querySelectorAll('[data-underline-wipe-swap]').forEach(function (el) {
if (el._uws) return;
el._uws = setup(el);
if (!reduced && el._uws.words.length > 1) io.observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><h1>
Write
<span data-underline-wipe-swap="clearly, boldly, freely">clearly</span>
</h1>