Stacked Headline Build
The lines of a headline arrive one at a time from below. Each new line bumps the ones above it up to make room, so the stack grows from the middle and stays centred.
Plan it
Build it
Ship it
How to use it
- Paste the snippet just before
</head>. - Add
data-stacked-headline-buildto a heading, with its lines separated by<br>(or each line in its own element).
Options
Add any of these attributes to the same element.
data-interval | Gap between lines. Default 520 ms. |
data-duration | Time for each line to land. Default 700 ms. |
data-loop | Clear the stack and build it again. |
data-hold | How long the full stack stays, when looping. Default 1800 ms. |
data-delay | Wait before starting. Default 0 ms. |
Screen readers hear the whole headline at once.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Stacked Headline Build: the lines of a headline arrive one by one from
// below, and each new line nudges the ones already there up to make room,
// so the stack stays centred as it grows.
// Usage: <h1 data-stacked-headline-build>Plan it<br>Build it<br>Ship it</h1>
// (separate lines with <br>, or put each line in its own child element).
// Optional: data-interval (ms between lines, default 520), data-duration
// (ms for each line to land, default 700), data-loop (clear the stack and
// build it again), data-hold (ms to stay, when looping, default 1800),
// data-delay (ms).
var DEFAULTS = { interval: 520, duration: 700, hold: 1800, delay: 0 };
var css =
'.shlb{position:relative}' +
'.shlb-line{display:block;will-change:transform,opacity}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
function setup(el) {
var parts;
if (el.querySelector('br')) {
parts = el.innerHTML.split(/<br\s*\/?>/i);
} else if (el.children.length) {
parts = Array.from(el.children).map(function (c) { return c.outerHTML; });
} else {
parts = [el.innerHTML];
}
parts = parts.map(function (p) { return p.trim(); }).filter(Boolean);
var holder = document.createElement('div');
var label = parts.map(function (p) { holder.innerHTML = p; return holder.textContent.trim(); }).join(' ');
el.setAttribute('aria-label', label);
el.classList.add('shlb');
el.innerHTML = '';
return parts.map(function (p) {
var line = document.createElement('span');
line.className = 'shlb-line';
line.setAttribute('aria-hidden', 'true');
line.innerHTML = p;
el.appendChild(line);
return line;
});
}
// Where every line must sit, as an offset from its natural place, when the
// first k lines are showing: the visible group centred in the full block.
function offsets(el, lines, k) {
var first = lines[0], last = lines[k - 1];
var top = first.offsetTop, bottom = last.offsetTop + last.offsetHeight;
return (el.clientHeight - top - bottom) / 2;
}
function play(el) {
var lines = el._shlb, d = el.dataset;
var loop = d.loop !== undefined && d.loop !== 'false';
var interval = +d.interval || DEFAULTS.interval;
var duration = +d.duration || DEFAULTS.duration;
var tl = anime.createTimeline({
onComplete: function () { if (loop) setTimeout(function () { play(el); }, 300); }
});
var lh = lines[0].offsetHeight;
lines.forEach(function (line) { anime.utils.set(line, { opacity: 0, y: 0, scale: 1 }); });
lines.forEach(function (line, i) {
var t = i * interval, off = offsets(el, lines, i + 1);
// The new line rises in from just below where it will sit...
tl.add(line, {
opacity: [0, 1],
y: [off + lh * 0.75, off],
scale: [0.92, 1],
duration: duration,
ease: 'outBack(1.3)'
}, t);
// ...and shoves everything above it up by the same step.
if (i) {
tl.add(lines.slice(0, i), { y: off, duration: duration * 0.85, ease: 'outBack(1.6)' }, t + 40);
}
});
var end = (lines.length - 1) * interval + duration;
if (loop) {
var out = end + (+d.hold || DEFAULTS.hold);
tl.add(lines, {
opacity: 0,
y: '-=' + Math.round(lh * 0.5),
duration: 420,
delay: anime.stagger(60),
ease: 'inQuad'
}, out);
}
}
function init() {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (!e.isIntersecting) return;
io.unobserve(e.target);
var el = e.target;
setTimeout(function () { play(el); }, +el.dataset.delay || DEFAULTS.delay);
});
}, { threshold: 0.3 });
document.querySelectorAll('[data-stacked-headline-build]').forEach(function (el) {
if (el._shlb) return;
var lines = el._shlb = setup(el);
if (reduced() || !lines.length) return;
lines.forEach(function (line) { line.style.opacity = 0; });
io.observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><h1 data-stacked-headline-build>Plan it<br>Build it<br>Ship it</h1>
<!-- or, a slower build that repeats: -->
<h1 data-stacked-headline-build data-interval="800" data-loop>Plan it<br>Build it<br>Ship it</h1>