Card Stack
Cards pin one after another and the ones passed recede into a deck.
free vanilla 16.6KB scrollstickycardsdepth
Options
| Option | Type | Default | Notes |
|---|---|---|---|
selector | string | ":scope > *" | Which descendants are cards, relative to the target. Defaults to its direct children. |
top | number | 24 | Where the first card pins, in px from the top of the scrollport. |
step | number | 12 | Extra pin offset per card in px — the sliver each card leaves on show. |
maxOffset | number | 64 | Ceiling on the accumulated step in px, so a long list stays in view. |
shrink | number | 0.04 | Scale lost per card of depth. 0.04 is 4% narrower per card behind. |
dim | number | 0.08 | Opacity lost per card of depth. 0 keeps buried cards fully opaque. |
maxDepth | number | 3 | Depth ceiling, so a long list does not shrink away to nothing. |
Install
-
Copy
card-stack.jsandcard-stack.cssinto your project. That is the whole component — it imports nothing. -
Include
tokens.cssonce, anywhere on the page. Every component reads its colour, spacing and motion from it. -
Link the two files, then call
createCardStackon a<div>.
<link rel="stylesheet" href="tokens.css" />
<link rel="stylesheet" href="card-stack.css" />
<script defer src="card-stack.js"></script> const instance = VC.createCardStack(
document.querySelector('#target'),
{}
);
// Later, on teardown:
instance.destroy();
The bundle is a classic script, so it works over file:// with no
server and no build step, and puts createCardStack on
window.VC. If you would rather have the ES module, it is
packages/registry/card-stack/ in the repo — same source, four
files once _utils/ comes with it.
View source
/**
* GENERATED — run pnpm build:components — do not edit.
*
* Source of truth: packages/registry/card-stack/card-stack.js
*
* Flattened from ES modules to one classic script by scripts/lab-bundle.js —
* byte for byte the build the Noir Portfolio template ships, so the preview
* you are watching is the file the copy button hands you.
*
* Exposes: window.VC.createCardStack
*/
(function () {
'use strict';
/* ── packages/registry/_utils/dom.js ───────────────────────────────── */
/**
* DOM construction and listener helpers.
*
* Zero dependencies. Every import specifier in this package must start with
* '.' or '/' — see scripts/check-isolation.js.
*/
const SVG_NS = 'http://www.w3.org/2000/svg';
/**
* @typedef {string | number | boolean | null | undefined} AttrValue
*/
/**
* Apply attributes to an element.
*
* - `class` and `className` both set the class attribute.
* - `style` takes a CSS string.
* - `data-*` and `aria-*` pass straight through.
* - `null`, `undefined` and `false` skip the attribute entirely, so callers can
* pass conditionals inline.
* - `true` sets a valueless attribute.
*
* @param {Element} node
* @param {Record<string, AttrValue>} attrs
* @returns {void}
*/
function applyAttrs(node, attrs) {
for (const [key, value] of Object.entries(attrs)) {
if (value === null || value === undefined || value === false) continue;
const name = key === 'className' ? 'class' : key;
node.setAttribute(name, value === true ? '' : String(value));
}
}
/**
* Append children, coercing strings to text nodes.
*
* @param {Element} node
* @param {Array<Node | string>} children
* @returns {void}
*/
function appendChildren(node, children) {
for (const child of children) {
node.append(child);
}
}
/**
* Create an HTML element.
*
* @template {keyof HTMLElementTagNameMap} K
* @param {K} tag
* @param {Record<string, AttrValue>} [attrs]
* @param {Array<Node | string>} [children]
* @returns {HTMLElementTagNameMap[K]}
*
* @example
* const card = el('article', { class: 'vc-card', 'aria-live': 'polite' }, [
* el('h3', null, ['Title'])
* ]);
*/
function el(tag, attrs, children) {
const node = document.createElement(tag);
if (attrs) applyAttrs(node, attrs);
if (children) appendChildren(node, children);
return node;
}
/**
* Create an SVG element. Needed because `document.createElement` produces an
* HTML element that renders as nothing inside an `<svg>`.
*
* @template {keyof SVGElementTagNameMap} K
* @param {K} tag
* @param {Record<string, AttrValue>} [attrs]
* @param {Array<Node | string>} [children]
* @returns {SVGElementTagNameMap[K]}
*/
function svgEl(tag, attrs, children) {
const node = /** @type {SVGElementTagNameMap[K]} */ (
document.createElementNS(SVG_NS, tag)
);
if (attrs) applyAttrs(node, attrs);
if (children) appendChildren(node, children);
return node;
}
/**
* Bind a listener and get its own teardown back.
*
* Returning the cleanup rather than exposing `off()` is what makes `destroy()`
* auditable: a component collects these in an array and the array length is
* the number of things it has to undo.
*
* The returned function is idempotent — calling it twice is harmless.
*
* @template {Event} [E=Event]
* @param {EventTarget} target
* @param {string} type
* @param {(event: E) => void} handler
* @param {AddEventListenerOptions | boolean} [options]
* @returns {() => void} cleanup
*
* @example
* const cleanups = [
* on(window, 'pointermove', onMove, { passive: true }),
* on(el, 'pointerleave', onLeave)
* ];
* // destroy: cleanups.forEach((off) => off());
*/
function on(target, type, handler, options) {
const listener = /** @type {EventListener} */ (/** @type {unknown} */ (handler));
target.addEventListener(type, listener, options);
let removed = false;
return () => {
if (removed) return;
removed = true;
target.removeEventListener(type, listener, options);
};
}
/* ── packages/registry/_utils/motion.js ────────────────────────────── */
/**
* prefers-reduced-motion, as a live value.
*
* Reading the media query once at module load is wrong: the user can change the
* setting while the page is open, and a component that cached the old answer
* keeps animating. Every animated component gates on `prefersReducedMotion()`
* and subscribes to `onReducedMotionChange()`.
*
* The contract: reduced motion means static but complete. Never blank.
*
* Zero dependencies.
*/
const QUERY = '(prefers-reduced-motion: reduce)';
/** @type {MediaQueryList | null} */
let query = null;
/**
* Lazily resolve the MediaQueryList. Returns null where matchMedia is missing,
* which is the SSR pass and some test environments.
*
* @returns {MediaQueryList | null}
*/
function mediaQuery() {
if (query) return query;
if (typeof matchMedia !== 'function') return null;
query = matchMedia(QUERY);
return query;
}
/**
* Whether the user has asked for reduced motion, right now.
*
* Defaults to `false` when the query is unavailable — animation is the design
* intent, and a server render has no user preference to read.
*
* @returns {boolean}
*/
function prefersReducedMotion() {
return mediaQuery()?.matches ?? false;
}
/**
* Subscribe to changes in the preference.
*
* @param {(reduced: boolean) => void} handler
* @returns {() => void} unsubscribe
*
* @example
* let reduced = prefersReducedMotion();
* const stopWatching = onReducedMotionChange((next) => {
* reduced = next;
* if (reduced) settleToFinalState();
* });
* // destroy: stopWatching();
*/
function onReducedMotionChange(handler) {
const media = mediaQuery();
if (!media) return () => {};
/** @param {MediaQueryListEvent} event */
const listener = (event) => handler(event.matches);
media.addEventListener('change', listener);
let removed = false;
return () => {
if (removed) return;
removed = true;
media.removeEventListener('change', listener);
};
}
/* ── packages/registry/_utils/raf.js ───────────────────────────────── */
/**
* One requestAnimationFrame loop for the whole page.
*
* Twelve components each starting a private rAF means twelve callbacks the
* browser schedules separately, and twelve places a leak can hide. Components
* register here and get a deregister function back; the loop starts on the
* first subscriber and stops on the last.
*
* Zero dependencies.
*/
/**
* @callback FrameCallback
* @param {number} time Timestamp from rAF, in ms since page load.
* @param {number} delta Ms since the previous frame. 0 on the first frame.
* @returns {void}
*/
/** @type {Set<FrameCallback>} */
const callbacks = new Set();
/** @type {number} Active rAF handle, or 0 when stopped. */
let handle = 0;
/** @type {number} Timestamp of the previous frame, or 0 before the first. */
let previous = 0;
/**
* @param {number} time
* @returns {void}
*/
function tick(time) {
handle = requestAnimationFrame(tick);
const delta = previous === 0 ? 0 : time - previous;
previous = time;
// Iterate a copy: a callback may deregister itself, or another, mid-frame.
for (const callback of [...callbacks]) {
callback(time, delta);
}
}
/**
* @returns {void}
*/
function start() {
if (handle !== 0) return;
if (typeof requestAnimationFrame !== 'function') return;
previous = 0;
handle = requestAnimationFrame(tick);
}
/**
* @returns {void}
*/
function stop() {
if (handle === 0) return;
cancelAnimationFrame(handle);
handle = 0;
previous = 0;
}
/**
* Run a callback on every frame until the returned function is called.
*
* The returned function is idempotent, so a `destroy()` that runs twice is
* harmless.
*
* @param {FrameCallback} callback
* @returns {() => void} deregister
*
* @example
* const stopFrame = onFrame((time, delta) => {
* x += velocity * delta;
* });
* // destroy: stopFrame();
*/
function onFrame(callback) {
callbacks.add(callback);
start();
let done = false;
return () => {
if (done) return;
done = true;
callbacks.delete(callback);
if (callbacks.size === 0) stop();
};
}
/**
* Number of live subscribers. Exists so the lab harness and the lifecycle
* checklist can assert that `destroy()` actually let go of the loop.
*
* @returns {number}
*/
function frameSubscriberCount() {
return callbacks.size;
}
/* ── packages/registry/card-stack/card-stack.js ────────────────────── */
/**
* Card Stack
*
* The work section: cards pin one after another and the ones already passed
* recede into a deck behind the newest arrival. Scroll-linked, never
* scroll-jacking — the page scrolls at its own speed throughout.
*
* @see NOTES.md
*/
const BASE = 'vc-card-stack';
const CARD = `${BASE}__card`;
const PINNED = `${BASE}--pinned`;
/**
* Extra px of daylight left below a focus ring when a covered card is scrolled
* back into view. Exactly zero would leave the ring flush against the edge of
* the card covering it.
*/
const FOCUS_CLEARANCE = 8;
/**
* Targets with a live instance. A second mount would add a second set of pins
* over the first, so it fails loudly instead.
*
* @type {WeakSet<HTMLElement>}
*/
const mounted = new WeakSet();
/**
* @typedef {object} CardStackOptions
* @property {string} [selector] Which descendants are cards, relative to the
* target. Defaults to its direct children.
* @property {number} [top] Where the first card pins, px from the top of the
* scrollport.
* @property {number} [step] Extra pin offset per card, px. This is the sliver
* of each card left visible once the next one has covered it.
* @property {number} [maxOffset] Ceiling on the accumulated step, px. Past it
* cards share a pin and cover each other completely.
* @property {number} [shrink] Scale lost per card of depth, 0.04 = 4%.
* @property {number} [dim] Opacity lost per card of depth.
* @property {number} [maxDepth] Depth ceiling, so a long list does not shrink
* away to nothing.
*/
/**
* @typedef {object} CardStackInstance
* @property {() => void} destroy
*/
/**
* @typedef {object} Card
* @property {HTMLElement} node
* @property {number} pin Distance from the top of the scrollport, px.
* @property {number} wroteScale Last scale written to the DOM.
* @property {number} wroteFade Last opacity written to the DOM.
*/
/**
* @param {HTMLElement} target
* @param {CardStackOptions} [options]
* @returns {CardStackInstance}
*/
function createCardStack(target, options = {}) {
if (mounted.has(target)) {
throw new Error(
'createCardStack: this element already has an instance — destroy it first'
);
}
const {
selector = ':scope > *',
top = 24,
step = 12,
maxOffset = 64,
shrink = 0.04,
dim = 0.08,
maxDepth = 3
} = options;
/** @type {Array<() => void>} */
const cleanups = [];
mounted.add(target);
target.classList.add(BASE);
cleanups.push(() => {
target.classList.remove(BASE);
mounted.delete(target);
});
/** @type {HTMLElement[]} */
const nodes = [...target.querySelectorAll(selector)].filter(
(node) => node instanceof HTMLElement
);
/** @type {Card[]} */
const cards = nodes.map((node, index) => ({
node,
pin: top + Math.min(index * step, maxOffset),
wroteScale: 1,
wroteFade: 1
}));
/* ── Nothing to stack ───────────────────────────────────────────────────
An empty section is a complete section. No classes on children, no
listeners, no frames — and no throw. */
if (cards.length === 0) {
return {
destroy() {
while (cleanups.length > 0) cleanups.pop()?.();
}
};
}
/* ── State ─────────────────────────────────────────────────────────────── */
let reduced = prefersReducedMotion();
let pinned = false;
let visible = true;
/**
* Where the stack sat on the previous frame. NaN forces the next frame to do
* the work regardless — see invalidate().
*/
let lastTop = Number.NaN;
/** @type {(() => void) | null} Live rAF subscription. */
let frameHandle = null;
/* ── Pinning ───────────────────────────────────────────────────────────
The stylesheet declares how a pinned card behaves; this decides whether it
is pinned at all. Sticky needs a per-card offset, and CSS cannot count
siblings into an arithmetic expression, so the offsets have to be written
from here anyway. Leaving `position: sticky` out of the unpinned state
means a page whose script never ran is a plain, readable column of cards
rather than a pile. */
/**
* @returns {void}
*/
function applyPins() {
if (pinned) return;
pinned = true;
for (const [index, card] of cards.entries()) {
card.node.style.setProperty('--vc-cs-pin', `${card.pin}px`);
card.node.style.setProperty('--vc-cs-index', String(index));
card.node.classList.add(CARD);
}
target.classList.add(PINNED);
}
/**
* Put every card back exactly as it was found. Safe to call twice.
*
* @returns {void}
*/
function removePins() {
pinned = false;
target.classList.remove(PINNED);
for (const card of cards) {
card.node.classList.remove(CARD);
card.node.style.removeProperty('--vc-cs-pin');
card.node.style.removeProperty('--vc-cs-index');
card.node.style.removeProperty('--vc-cs-scale');
card.node.style.removeProperty('--vc-cs-fade');
card.wroteScale = 1;
card.wroteFade = 1;
if (card.node.getAttribute('style') === '') card.node.removeAttribute('style');
}
}
cleanups.push(removePins);
/* ── Depth ─────────────────────────────────────────────────────────────── */
/**
* Push a card's depth to the DOM, skipping writes that would not change a
* rendered pixel.
*
* @param {Card} card
* @param {number} depth Cards stacked on top of this one, fractional.
* @returns {void}
*/
function write(card, depth) {
const scale = Math.round((1 - shrink * depth) * 1000) / 1000;
if (scale !== card.wroteScale) {
card.node.style.setProperty('--vc-cs-scale', String(scale));
card.wroteScale = scale;
}
const fade = Math.round((1 - dim * depth) * 1000) / 1000;
if (fade !== card.wroteFade) {
card.node.style.setProperty('--vc-cs-fade', String(fade));
card.wroteFade = fade;
}
}
/**
* Measure everything, then write everything.
*
* The split is the point: interleaving a read and a write per card makes the
* browser lay out once per card. Reading the whole stack first costs one
* layout for the frame however many cards there are.
*
* Geometry is read live rather than cached because it is cheap here and
* because a cache would have to be invalidated by every image that finishes
* loading. Two properties make the numbers safe to read while the transform
* is applied: `transform-origin: top center` leaves a scaled card's top edge
* exactly where it was, and `offsetHeight` is layout height, which a
* transform never touches. So nothing measured here is fed by its own output.
*
* @returns {void}
*/
function update() {
const count = cards.length;
/** @type {number[]} */
const tops = [];
/** @type {number[]} */
const heights = [];
for (const card of cards) {
tops.push(card.node.getBoundingClientRect().top);
heights.push(card.node.offsetHeight);
}
/* How far card i has been covered by card i+1, 0 → 1. The travel is the
part of card i that ends up hidden: its height, less the sliver the two
pins leave between them. */
/** @type {number[]} */
const covered = new Array(count).fill(0);
for (let i = 0; i < count - 1; i += 1) {
const travel = heights[i] - (cards[i + 1].pin - cards[i].pin);
/* A card shorter than the gap between the two pins is never covered at
all, and dividing by that travel would report coverage backwards. */
if (travel <= 0) continue;
const overlap = tops[i] + heights[i] - tops[i + 1];
covered[i] = Math.min(1, Math.max(0, overlap / travel));
}
/* Depth is cumulative, so the deck recedes rather than every buried card
sitting at the same scale: card i gains a card of depth for its own
coverage and for every coverage below it. One suffix sum, bottom up. */
let depth = 0;
for (let i = count - 1; i >= 0; i -= 1) {
depth += covered[i];
write(cards[i], Math.min(depth, maxDepth));
}
}
/* ── Frames ────────────────────────────────────────────────────────────
Scroll-linked work belongs on a frame, not on the scroll event: the event
can fire several times between paints and the extra runs are thrown away.
So scroll only wakes the loop, and the loop gives the frame back as soon
as the stack stops moving. Idle cost is zero. */
/**
* @returns {void}
*/
function frame() {
const now = target.getBoundingClientRect().top;
if (now === lastTop) {
sleep();
return;
}
lastTop = now;
update();
}
/**
* @returns {void}
*/
function wake() {
if (frameHandle || reduced || !visible || !pinned) return;
frameHandle = onFrame(frame);
}
/**
* @returns {void}
*/
function sleep() {
if (!frameHandle) return;
frameHandle();
frameHandle = null;
}
cleanups.push(sleep);
/**
* Something other than scrolling changed the geometry — a resize, a reflow,
* the stack coming back into view. The stack's own top may be identical, so
* the next frame has to be told not to trust it.
*
* @returns {void}
*/
function invalidate() {
lastTop = Number.NaN;
wake();
}
/* ── Scroll ────────────────────────────────────────────────────────────
Capture, on window: scroll events do not bubble, but they do run capture
listeners on the way down, so this one hears a nested scroller too — and
a nested scroller is exactly what `position: sticky` pins against. */
cleanups.push(
on(window, 'scroll', wake, { passive: true, capture: true }),
on(window, 'resize', invalidate, { passive: true })
);
/* ── Off-screen ────────────────────────────────────────────────────────
A stack scrolled past should not be costing frames. Cards are left in the
state they were in, so coming back is a continuation, not a jump. */
if (typeof IntersectionObserver === 'function') {
const observer = new IntersectionObserver((entries) => {
visible = entries.some((entry) => entry.isIntersecting);
if (visible) invalidate();
else sleep();
});
observer.observe(target);
cleanups.push(() => observer.disconnect());
}
/* ── Reflow ────────────────────────────────────────────────────────────
A card that grew — a late image, a font swap, a narrower viewport rewrapping
a title onto two lines — changes how much of it there is to cover. */
if (typeof ResizeObserver === 'function') {
const observer = new ResizeObserver(() => invalidate());
observer.observe(target);
cleanups.push(() => observer.disconnect());
}
/* ── Keyboard ──────────────────────────────────────────────────────────
A pinned card is covered by the next one, which means something focusable
inside it can be tabbed to while it is behind another card. The browser's
own scroll-into-view cannot fix this: the card is sticky, so it already
sits at the top of the scrollport and there is nothing for the browser to
scroll to.
What actually uncovers it is scrolling *back*, which moves the covering
card down while the pinned one stays put. */
/**
* Nearest ancestor that scrolls vertically, falling back to the document.
* This is the same box sticky pins against.
*
* @param {HTMLElement} node
* @returns {Element}
*/
function scrollport(node) {
/** @type {HTMLElement | null} */
let current = node.parentElement;
while (current) {
const overflow = getComputedStyle(current).overflowY;
const scrolls = overflow === 'auto' || overflow === 'scroll';
if (scrolls && current.scrollHeight > current.clientHeight) return current;
current = current.parentElement;
}
return document.scrollingElement ?? document.documentElement;
}
cleanups.push(
on(target, 'focusin', (/** @type {FocusEvent} */ event) => {
if (!pinned) return;
const node = event.target;
if (!(node instanceof Element)) return;
const index = cards.findIndex((card) => card.node.contains(node));
/* Nothing covers the last card. */
if (index < 0 || index >= cards.length - 1) return;
/* Measure the focused element, not the card: focus landing in the
sliver still on show needs no scrolling at all. */
const hidden =
node.getBoundingClientRect().bottom -
cards[index + 1].node.getBoundingClientRect().top;
if (hidden <= 0) return;
scrollport(target).scrollBy(0, -(hidden + FOCUS_CLEARANCE));
})
);
/* ── Reduced motion ────────────────────────────────────────────────────
Static, but complete: every card is present and readable in full, in
order. Nothing pins, nothing shrinks, nothing fades, and no frame is
taken. The stylesheet carries the same rule for a preference that flips
between a frame being scheduled and it running. */
/**
* @returns {void}
*/
function applyMotionPreference() {
if (reduced) {
sleep();
removePins();
} else {
applyPins();
invalidate();
}
}
cleanups.push(
onReducedMotionChange((next) => {
reduced = next;
applyMotionPreference();
})
);
applyMotionPreference();
return {
destroy() {
while (cleanups.length > 0) cleanups.pop()?.();
}
};
}
/* ── exports ─────────────────────────────────────────────────────────── */
var VC = (window.VC = window.VC || {});
VC.createCardStack = createCardStack;
/* Every export in the graph, for checklist assertions from the demo page. */
VC.__all = {
el: el,
svgEl: svgEl,
on: on,
prefersReducedMotion: prefersReducedMotion,
onReducedMotionChange: onReducedMotionChange,
onFrame: onFrame,
frameSubscriberCount: frameSubscriberCount,
createCardStack: createCardStack
};
})();
View source
/* GENERATED — run pnpm build:components. Source: packages/registry/card-stack/card-stack.css */
/**
* Card Stack
*
* Reads tokens, never defines them. No global rules — every selector is scoped
* under .vc-card-stack. Nothing here paints: no background, no border, no
* radius. A card looks like the buyer's card, and the stack only decides where
* it sits and how far back it is.
*
* The pinning is declared here but switched on from JavaScript, which adds
* .vc-card-stack__card once it has written that card's offset. A page whose
* script never ran is then a plain readable column of cards rather than a pile
* with no depth to it.
*/
/**
* transform-origin is load-bearing twice over. It makes the card recede toward
* the sliver of itself still on show, which is what reads as depth — and it
* leaves the top edge exactly where it was, so the factory can measure a
* scaled card without measuring its own output.
*
* z-index is explicit rather than left to source order. `position: sticky`
* makes every card a stacking context whatever the value, so this costs
* nothing and stops a stray z-index inside a card reordering the deck.
*
* scroll-margin-top keeps an anchor link — #work, #project-3 — from landing
* under the pinned card above it.
*/
.vc-card-stack__card {
position: sticky;
top: var(--vc-cs-pin, 0px);
z-index: var(--vc-cs-index, auto);
transform: scale(var(--vc-cs-scale, 1));
transform-origin: top center;
opacity: var(--vc-cs-fade, 1);
scroll-margin-top: var(--vc-cs-pin, 0px);
}
/**
* Belt and braces. The factory already declines to pin anything under reduced
* motion; this covers a preference that flips after a frame is scheduled and
* before it runs.
*
* `relative`, not `static`: both lay out identically in flow, but a card that
* was relying on being a containing block for something absolutely positioned
* inside it keeps that either way. Which is the same reason the pinned state
* can be dropped without the card's own contents moving.
*/
@media (prefers-reduced-motion: reduce) {
.vc-card-stack__card {
position: relative;
top: auto;
transform: none;
opacity: 1;
scroll-margin-top: 0;
}
}