Line Reveal
A paragraph arrives one line at a time, each line rising from behind its own mask. The lines are worked out from where the browser really wraps the text, and again whenever the width changes.
Good motion guides the eye. Each line arrives in turn, so the reader takes in one thought at a time, in the order you wrote it.
How to use it
- Paste the snippet just before
</head>. - Add
data-line-revealto a paragraph or heading that wraps onto more than one line.
Options
Add any of these attributes to the same element.
data-loop | Rise in, hold, rise out and in again. |
data-hold | How long the text stays before leaving, when looping. Default 2400 ms. |
data-duration | How long each line takes to rise. Default 1000 ms. |
data-stagger | Gap between lines. Default 120 ms. |
data-delay | Wait before the first line. Default 0 ms. |
Works on plain text: links or bold inside the element are flattened to text when it’s split into lines.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Line Reveal: a paragraph arrives one line at a time, each line rising from
// behind its own mask. Lines are worked out from where the browser actually
// wraps the text, and again whenever the width changes.
// Usage: <p data-line-reveal>A paragraph that wraps onto a few lines…</p>
// Optional: data-loop (lines rise out and in again), data-hold (ms to stay
// before leaving), data-duration (ms per line), data-stagger (ms between
// lines), data-delay (ms before starting).
var DEFAULTS = { duration: 1000, stagger: 120, delay: 0, hold: 2400 };
var css =
'.lrv-line{display:block;clip-path:inset(-0.05em -0.3em -0.15em -0.3em)}' +
'.lrv-inner{display:inline-block;will-change:transform}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
// Lay the words out, see where the browser breaks them, then rebuild the
// text as one masked block per line.
function split(el, words) {
el.textContent = '';
var spans = words.map(function (w, i) {
if (i) el.appendChild(document.createTextNode(' '));
var s = document.createElement('span');
s.textContent = w;
el.appendChild(s);
return s;
});
var rows = [];
var top = null;
spans.forEach(function (s, i) {
if (top === null || s.offsetTop > top + 2) { rows.push([]); top = s.offsetTop; }
rows[rows.length - 1].push(words[i]);
});
el.textContent = '';
return rows.map(function (row) {
var line = document.createElement('span');
line.className = 'lrv-line';
line.setAttribute('aria-hidden', 'true');
var inner = document.createElement('span');
inner.className = 'lrv-inner';
inner.textContent = row.join(' ');
line.appendChild(inner);
el.appendChild(line);
return inner;
});
}
function play(el) {
var st = el._lrv;
var d = el.dataset;
var loop = d.loop !== undefined && d.loop !== 'false';
var duration = +d.duration || DEFAULTS.duration;
var stagger = +d.stagger || DEFAULTS.stagger;
var tl = anime.createTimeline({ loop: loop, loopDelay: 300, delay: st.played ? 0 : +d.delay || DEFAULTS.delay });
tl.add(st.lines, {
y: ['110%', '0%'],
duration: duration,
ease: 'outQuart',
delay: anime.stagger(stagger)
});
if (loop) {
tl.add(st.lines, {
y: ['0%', '-110%'],
duration: duration * 0.6,
ease: 'inQuart',
delay: anime.stagger(stagger * 0.6)
}, '+=' + (+d.hold || DEFAULTS.hold));
}
st.tl = tl;
st.played = true;
}
function hide(lines) {
lines.forEach(function (l) { l.style.transform = 'translateY(110%)'; });
}
function init() {
if (reduced()) return;
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (!e.isIntersecting) return;
io.unobserve(e.target);
play(e.target);
});
}, { threshold: 0.2 });
var els = [];
document.querySelectorAll('[data-line-reveal]').forEach(function (el) {
if (el._lrv) return;
var text = el.textContent.replace(/\s+/g, ' ').trim();
el.setAttribute('aria-label', text);
var st = (el._lrv = { words: text.split(' ') });
st.lines = split(el, st.words);
st.width = el.clientWidth;
hide(st.lines);
els.push(el);
io.observe(el);
});
if (!els.length) return;
function resplit(el) {
var st = el._lrv;
if (st.tl) st.tl.pause();
st.lines = split(el, st.words);
st.width = el.clientWidth;
if (!st.played) hide(st.lines);
else if (el.dataset.loop !== undefined && el.dataset.loop !== 'false') play(el);
}
// Web fonts can change where lines break, so split again once they load...
if (document.fonts) document.fonts.ready.then(function () { els.forEach(resplit); });
// ...and whenever the width changes.
var timer;
window.addEventListener('resize', function () {
clearTimeout(timer);
timer = setTimeout(function () {
els.forEach(function (el) { if (el.clientWidth !== el._lrv.width) resplit(el); });
}, 200);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><p data-line-reveal>
Good motion guides the eye. Each line arrives in turn,
so the reader takes in one thought at a time.
</p>