Sticky Card Stack
Cards pin near the top of the view as you scroll, and each new card slides up over the last until they pile into a deck. Covered cards shrink back and dim a little, leaving their top edges peeking out.
Scroll inside ↓
01
DesignSketch the idea
02
BuildMake it real
03
ShipPut it out there
04
GrowListen and improve
How to use it
- Paste the snippet just before
</head>. - Add
data-sticky-card-stackto the element holding the cards. Each direct child becomes a card. - Space the cards out with
margin-top(notmargin-bottom, which cuts their pinning short): it sets how long each one takes to slide over the last.
Options
Add any of these attributes to the same element.
data-top | Where the cards pin, in px from the top of the view. Default 24. |
data-offset | How much lower each card pins than the one before, so the edges show. Default 14 px. |
data-scale | How much smaller each covered card gets. Default 0.06. |
data-dim | How much darker each covered card gets. Default 0.12. |
The pinning is plain CSS position: sticky, so nothing between the cards and the page can have overflow: hidden. Works inside scrolling boxes too.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Sticky Card Stack: cards pin near the top of the view as you scroll and
// the next card slides up over them, so they pile into a deck. Cards that
// get covered shrink back and dim a little, leaving their top edge peeking
// out above the card in front.
// Usage: <div data-sticky-card-stack>
// <div class="card">…</div>
// <div class="card">…</div>
// <div class="card">…</div>
// </div>
// Optional: data-top (px from the top of the view where cards pin, default
// 24), data-offset (px each card pins lower than the one before, default
// 14), data-scale (how much smaller each covered card gets, default 0.06),
// data-dim (how much darker, default 0.12).
// Space the cards out with margin-top (not margin-bottom, which would cut
// their pinning short) to set how long each one takes to slide over the last.
// Works inside scrolling boxes too: it follows the nearest scrolling parent.
var DEFAULTS = { top: 24, offset: 14, scale: 0.06, dim: 0.12 };
function scroller(el) {
for (var p = el.parentElement; p && p !== document.body; p = p.parentElement) {
var o = getComputedStyle(p).overflowY;
if ((o === 'auto' || o === 'scroll') && p.scrollHeight > p.clientHeight) return p;
}
return null;
}
function num(v, fallback) { return v !== undefined && v !== '' && !isNaN(+v) ? +v : fallback; }
function setup(el) {
var d = el.dataset;
var top = num(d.top, DEFAULTS.top), offset = num(d.offset, DEFAULTS.offset);
var scale = num(d.scale, DEFAULTS.scale), dim = num(d.dim, DEFAULTS.dim);
var cards = Array.prototype.slice.call(el.children).map(function (c, i) {
c.style.position = 'sticky';
c.style.top = top + i * offset + 'px';
c.style.transformOrigin = '50% 0';
c.style.willChange = 'transform, filter';
return { el: c, pin: top + i * offset };
});
var box = scroller(el);
var queued = false;
function update() {
queued = false;
var viewTop = box ? box.getBoundingClientRect().top + box.clientTop : 0;
// How far each card has slid over the one before it: 0 while it's still
// a card's height away, 1 once it has pinned on top.
var cover = cards.map(function (c, i) {
if (!i) return 0;
var prev = cards[i - 1].el.offsetHeight || 1;
var gap = c.el.getBoundingClientRect().top - viewTop - c.pin;
return Math.max(0, Math.min(1, 1 - gap / prev));
});
cards.forEach(function (c, i) {
// Every card in front adds to how far back this one sits.
var depth = 0;
for (var j = i + 1; j < cards.length; j++) depth += cover[j];
if (Math.abs(depth - (c.depth || 0)) < 0.002) return;
c.depth = depth;
anime.utils.set(c.el, {
scale: 1 - scale * depth,
filter: depth > 0.001 ? 'brightness(' + (1 - dim * Math.min(depth, 3)).toFixed(3) + ')' : 'none'
});
});
}
function onScroll() {
if (queued) return;
queued = true;
requestAnimationFrame(update);
}
(box || window).addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onScroll);
update();
}
function init() {
var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
document.querySelectorAll('[data-sticky-card-stack]').forEach(function (el) {
if (el._scs) return;
el._scs = true;
// With reduced motion the cards still pin and stack (that's just
// scrolling), they just don't shrink and dim.
if (reduced) {
var d = el.dataset, top = num(d.top, DEFAULTS.top), offset = num(d.offset, DEFAULTS.offset);
Array.prototype.forEach.call(el.children, function (c, i) { c.style.position = 'sticky'; c.style.top = top + i * offset + 'px'; });
return;
}
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-sticky-card-stack>
<div class="card">…</div>
<div class="card" style="margin-top: 40vh">…</div>
<div class="card" style="margin-top: 40vh">…</div>
</div>