Word Card Stack
The word in your sentence sits on the top card of a little stack. The card tips and falls away to show the next word underneath, the stack shuffles forward and a fresh card slides in at the back.
Plan your week
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-word-card-stack.
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 card to fall. Default 800 ms. |
data-fall | down (default) drops the card, tipping to either side. left or right flicks it off that way. |
data-card | The cards’ colour. Default white. |
data-depth | How many cards show behind the top one. Default 2. |
The cards are as wide as the longest word, so the sentence never shifts. The stack peeks out a little below the line.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Word Card Stack: each word sits on a card in a little stack. The top card
// tips and falls away to show the next word, the stack shuffles forward
// and a fresh card slides in at the back.
// Usage: <h1>Plan your <span data-word-card-stack="week, launch, trip">week</span></h1>
// Optional: data-hold (ms per word, default 1800), data-duration (ms per
// fall, default 800), data-fall ("down", the default, "left" or "right"),
// data-card (card colour, default white), data-depth (cards visible
// behind the top one, default 2).
var DEFAULTS = { hold: 1800, duration: 800, fall: 'down', card: '#fff', depth: 2 };
var css =
'.wks{display:inline-block;position:relative;white-space:nowrap;vertical-align:baseline;text-align:center;padding:.04em .28em .1em;margin:0 .06em}' +
'.wks-size{visibility:hidden;display:block}' +
'.wks-card{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;padding-bottom:.06em;border-radius:.16em;' +
'box-shadow:0 0 0 1px rgba(20,10,40,.1),0 .04em .14em rgba(20,10,40,.14);will-change:transform,opacity}' +
'.wks-card>span{display:block}' +
'.wks-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;
}
// Where a card sits at a given depth in the stack (0 = top).
function pose(depth) {
return { y: (depth * 0.13) + 'em', scale: 1 - depth * 0.07, opacity: depth > 0 ? 1 - depth * 0.18 : 1, rotate: 0, x: '0em' };
}
function card(s, word, depth) {
var c = document.createElement('span');
c.className = 'wks-card';
c.setAttribute('aria-hidden', 'true');
c.style.background = s.el.dataset.card || DEFAULTS.card;
c.style.zIndex = 10 - depth;
// Only the top card's word shows; the ones behind are blank backs.
span('', c, word).style.opacity = depth ? 0 : 1;
s.el.appendChild(c);
anime.utils.set(c, pose(depth));
return c;
}
function setup(el) {
var words = (el.getAttribute('data-word-card-stack') || el.textContent)
.split(',').map(function (w) { return w.trim(); }).filter(Boolean);
el.classList.add('wks');
el.textContent = '';
span('wks-sr', el, words.join(', '));
// An invisible copy of the longest word gives every card the same size,
// so the sentence never shifts.
var longest = words.reduce(function (a, b) { return b.length > a.length ? b : a; }, '');
span('wks-size', el, longest).setAttribute('aria-hidden', 'true');
var s = { el: el, words: words, cards: [], i: 0, visible: false, started: false };
s.depth = Math.max(0, Math.min(4, el.dataset.depth !== undefined ? +el.dataset.depth : DEFAULTS.depth));
var n = Math.min(words.length, s.depth + 1);
for (var k = 0; k < n; k++) s.cards.push(card(s, words[k % words.length], k));
// The stack peeks out below the line; leave room for it.
el.style.marginBottom = (s.depth * 0.13) + 'em';
return s;
}
function swap(s, done) {
var d = s.el.dataset;
var t = +d.duration || DEFAULTS.duration;
var fall = d.fall || DEFAULTS.fall;
var top = s.cards.shift();
var side = fall === 'left' ? -1 : fall === 'right' ? 1 : (Math.random() < 0.5 ? -1 : 1);
s.i = (s.i + 1) % s.words.length;
// 1. The top card tips over and drops away.
top.style.zIndex = 20;
anime.animate(top, {
y: fall === 'down' ? ['0em', '1.8em'] : ['0em', '0.6em'],
x: fall === 'down' ? ['0em', side * 0.35 + 'em'] : ['0em', side * 2.2 + 'em'],
rotate: [0, side * (fall === 'down' ? 16 : 28)],
duration: t,
ease: 'in(2.2)',
onComplete: function () { top.remove(); }
});
anime.animate(top, { opacity: [1, 0], duration: t * 0.55, delay: t * 0.3, ease: 'linear' });
// 2. The rest of the stack moves up a place, and the new top card's
// word comes into view.
s.cards.forEach(function (c, k) {
c.style.zIndex = 10 - k;
anime.animate(c, Object.assign(pose(k), { duration: t * 0.75, delay: t * 0.2 + k * 50, ease: 'outBack(1.4)' }));
if (k === 0) anime.animate(c.firstChild, { opacity: 1, duration: t * 0.4, delay: t * 0.15, ease: 'out(2)' });
});
// 3. A new card fades in at the back.
var depth = s.cards.length;
var c = card(s, s.words[(s.i + depth) % s.words.length], depth);
anime.utils.set(c, { opacity: 0, y: (depth * 0.13 + 0.12) + 'em' });
anime.animate(c, Object.assign(pose(depth), { duration: t * 0.7, delay: t * 0.45, ease: 'out(3)' }));
s.cards.push(c);
setTimeout(done, t + 100);
}
// 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) {
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._wks;
s.visible = e.isIntersecting;
if (s.visible && !s.started) { s.started = true; start(s); }
});
}, { threshold: 0.3 });
document.querySelectorAll('[data-word-card-stack]').forEach(function (el) {
if (el._wks) return;
el._wks = setup(el);
if (!reduced && el._wks.words.length > 1) io.observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><h1>
Plan your
<span data-word-card-stack="week, launch, trip">week</span>
</h1>