Sticky Scroll Stack
Cards that pin and pile up as you scroll: each one shrinks and dims as the next slides over it, with a progress index on the side that jumps between cards. Hand-written scroll logic: a built-in scroll box by default, or the page, or your own scroller.
DOM + CSS0 deps1 file · 15 KB TSXreduced-motion aware
Props
"use client";
import { useEffect, useRef, useState, type RefObject } from "react";
export type StickyScrollStackCard = { title: string; body: string };
export type StickyScrollStackProps = {
/** How much each card shrinks per card stacked on top of it (0.06 = 6%). */
scaleStep?: number;
/**
* What scrolls. Leave it out and the component renders its own scroll box
* (see height). Pass "window" to follow the page scroll, or the ref of your
* own overflow-y-auto element.
*/
scroller?: RefObject<HTMLElement | null> | "window";
/** Height of the built-in scroll box (only used when scroller is not set). */
height?: number | string;
/** Card content. Falls back to four sample cards. */
cards?: StickyScrollStackCard[];
/** Where the first card pins, in px from the top of the scroll area. */
top?: number;
/** How far each stacked card sits below the one under it, in px. */
stackOffset?: number;
/** Space between cards before they stack, in px (more = longer scroll). */
gap?: number;
/** Card height: a number of px or any CSS length. */
cardHeight?: number | string;
/** Root width: the root is min(100%, width). Any CSS length. */
width?: string;
/** Show the progress index beside the stack. */
showIndex?: boolean;
className?: string;
};
const DEFAULT_CARDS: StickyScrollStackCard[] = [
{ title: "Design", body: "Sketch the shape before you touch a component." },
{ title: "Build", body: "Wire it up with the real content, not lorem ipsum." },
{ title: "Ship", body: "Small, reversible, boring deploys beat big risky ones." },
{ title: "Measure", body: "Read the one number that moved, then decide what to cut." },
];
const HUES = [212, 168, 38, 292];
const TWEEN_MS = 620;
const clamp01 = (n: number) => (n < 0 ? 0 : n > 1 ? 1 : n);
/** A small line illustration per card (pure SVG, no assets). */
function Art({ kind, hue }: { kind: number; hue: number }) {
const ink = `hsl(${hue} 92% 74%)`;
const wash = `hsl(${hue} 90% 60% / 0.14)`;
const draw =
"[stroke-dasharray:1] [stroke-dashoffset:1] transition-[stroke-dashoffset] duration-[900ms] ease-[cubic-bezier(0.22,1,0.36,1)] group-data-[in=true]/card:[stroke-dashoffset:0] motion-reduce:transition-none";
const common = { fill: "none", stroke: ink, strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round" } as const;
return (
<svg viewBox="0 0 160 120" className="block h-auto w-full" aria-hidden="true">
{kind === 0 && (
<g>
<rect x="24" y="24" width="112" height="68" rx="10" fill={wash} stroke="rgba(255,255,255,0.28)" strokeDasharray="3 4" />
{[[24, 24], [136, 24], [24, 92], [136, 92]].map(([x, y]) => (
<rect key={`${x}-${y}`} x={(x ?? 0) - 3.5} y={(y ?? 0) - 3.5} width="7" height="7" rx="1.5" fill="#0c0d10" stroke={ink} strokeWidth="1.2" />
))}
<circle cx="56" cy="58" r="13" {...common} />
<rect x="82" y="46" width="38" height="6" rx="3" fill="rgba(255,255,255,0.22)" />
<rect x="82" y="60" width="26" height="6" rx="3" fill="rgba(255,255,255,0.12)" />
<path pathLength={1} d="M24 106 C 50 78, 88 120, 136 100" {...common} className={draw} />
</g>
)}
{kind === 1 && (
<g>
{[44, 26, 8].map((dy, n) => (
<path
key={dy}
d={`M80 ${dy + 14} L132 ${dy + 36} L80 ${dy + 58} L28 ${dy + 36} Z`}
fill={n === 2 ? wash : "rgba(255,255,255,0.04)"}
stroke={n === 2 ? ink : "rgba(255,255,255,0.22)"}
strokeWidth="1.3"
strokeLinejoin="round"
/>
))}
<path pathLength={1} d="M80 22 L106 33 L80 44 L54 33 Z" {...common} className={draw} />
<path d="M80 70 V 96" stroke="rgba(255,255,255,0.18)" strokeDasharray="2 4" />
</g>
)}
{kind === 2 && (
<g>
<circle cx="80" cy="62" r="38" fill={wash} stroke="rgba(255,255,255,0.14)" strokeWidth="1.2" />
<circle pathLength={1} cx="80" cy="62" r="38" {...common} transform="rotate(-90 80 62)" className={draw} />
<path pathLength={1} d="M58 80 L92 48 M78 46 H94 V62" {...common} strokeWidth={1.8} className={draw} />
<circle cx="58" cy="80" r="2.6" fill={ink} />
</g>
)}
{kind === 3 && (
<g>
{[22, 38, 30, 54, 70].map((h, n) => (
<rect key={n} x={26 + n * 22} y={100 - h} width="14" height={h} rx="4" fill={n === 4 ? wash : "rgba(255,255,255,0.08)"} stroke={n === 4 ? ink : "rgba(255,255,255,0.2)"} strokeWidth="1.2" />
))}
<path pathLength={1} d="M33 74 L55 58 L77 66 L99 40 L121 22" {...common} className={draw} />
<circle cx="121" cy="22" r="3.2" fill={ink} />
</g>
)}
</svg>
);
}
export function StickyScrollStack({
scaleStep = 0.06,
scroller,
cards = DEFAULT_CARDS,
top = 16,
stackOffset = 12,
gap = 24,
cardHeight = 192,
width = "18rem",
height = 420,
showIndex = true,
className = "",
}: StickyScrollStackProps) {
const boxRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const columnRef = useRef<HTMLDivElement>(null);
const spacerRef = useRef<HTMLDivElement>(null);
const fillRef = useRef<HTMLSpanElement>(null);
const goRef = useRef<(i: number) => void>(() => {});
const [active, setActive] = useState(0);
const count = cards.length;
useEffect(() => {
const track = trackRef.current;
const column = columnRef.current;
if (!track || !column || count === 0) return;
const el = scroller === "window" ? null : scroller ? scroller.current : boxRef.current;
const target: HTMLElement | Window = el ?? window;
const cardEls = Array.from(column.querySelectorAll<HTMLElement>("[data-sss-card]"));
const dims = cardEls.map((c) => c.querySelector<HTMLElement>("[data-sss-dim]"));
const reduced = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const by = (dy: number) => {
if (el) el.scrollBy({ top: dy, behavior: "instant" });
else window.scrollBy({ top: dy, behavior: "instant" });
};
let alive = true;
let raf = 0;
let tween = 0;
let cardH = 0;
// Distance of the track's top edge below the scrollport's top edge, in layout px
// (library cards scale their previews, so divide the scale out).
const trackOffset = () => {
const r = track.getBoundingClientRect();
const k = track.offsetWidth ? r.width / track.offsetWidth : 1;
const portTop = el ? el.getBoundingClientRect().top + el.clientTop * k : 0;
return (r.top - portTop) / k;
};
const measure = () => {
cardH = cardEls[0]?.offsetHeight ?? 0;
const portH = el ? el.clientHeight : window.innerHeight;
const spacer = spacerRef.current;
// Enough trailing room that the last card can finish pinning.
if (spacer) spacer.style.height = `${Math.max(0, portH - (top + (count - 1) * stackOffset) - cardH) + 48}px`;
};
const update = () => {
raf = 0;
if (!alive || !cardH) return;
const rel = trackOffset();
const span = Math.max(1, cardH - stackOffset);
const step = cardH + gap;
// p[m]: how far card m+1 has travelled over card m (0 = not touching, 1 = pinned on it).
const p: number[] = [];
let total = 0;
for (let m = 0; m < count - 1; m++) {
const j = m + 1;
const d = rel + j * step - (top + j * stackOffset);
const v = clamp01(1 - d / span);
p.push(v);
total += v;
}
let depth = 0;
for (let i = count - 1; i >= 0; i--) {
depth += p[i] ?? 0;
const c = cardEls[i];
if (!c) continue;
c.style.transform = depth > 0.0005 ? `scale(${Math.max(0.6, 1 - scaleStep * depth).toFixed(4)})` : "";
const dim = dims[i];
if (dim) dim.style.opacity = Math.min(0.6, depth * 0.2).toFixed(3);
c.setAttribute("data-in", i === 0 || (p[i - 1] ?? 0) > 0.35 ? "true" : "false");
}
let a = 0;
for (const v of p) if (v >= 0.5) a++;
setActive(a);
if (fillRef.current) fillRef.current.style.transform = `scaleY(${count > 1 ? (total / (count - 1)).toFixed(4) : 0})`;
};
const schedule = () => {
if (!raf) raf = requestAnimationFrame(update);
};
const remeasure = () => {
measure();
schedule();
};
goRef.current = (i: number) => {
const delta = i * (cardH + gap) - top - i * stackOffset + trackOffset();
cancelAnimationFrame(tween);
if (Math.abs(delta) < 2) return;
if (reduced()) {
by(delta);
return;
}
let t0 = -1;
let done = 0;
const tick = (ts: number) => {
if (!alive) return;
if (t0 < 0) t0 = ts;
const t = Math.min(1, (ts - t0) / TWEEN_MS);
const want = delta * (1 - Math.pow(1 - t, 3));
by(want - done);
done = want;
if (t < 1) tween = requestAnimationFrame(tick);
};
tween = requestAnimationFrame(tick);
};
const stopTween = () => cancelAnimationFrame(tween);
measure();
schedule();
target.addEventListener("scroll", schedule, { passive: true });
target.addEventListener("wheel", stopTween, { passive: true });
target.addEventListener("touchstart", stopTween, { passive: true });
window.addEventListener("resize", remeasure);
const ro = new ResizeObserver(remeasure);
ro.observe(track);
if (el) ro.observe(el);
return () => {
alive = false;
cancelAnimationFrame(raf);
cancelAnimationFrame(tween);
target.removeEventListener("scroll", schedule);
target.removeEventListener("wheel", stopTween);
target.removeEventListener("touchstart", stopTween);
window.removeEventListener("resize", remeasure);
ro.disconnect();
goRef.current = () => {};
};
}, [scroller, count, top, stackOffset, gap, scaleStep, cardHeight]);
const own = scroller === undefined;
const body = (
<div
ref={trackRef}
className={`grid items-start gap-x-1 @md:gap-x-3 ${showIndex ? "grid-cols-[minmax(0,1fr)_auto]" : "grid-cols-[minmax(0,1fr)]"}`}
>
<div ref={columnRef}>
{cards.map((card, i) => {
const hue = HUES[i % HUES.length] ?? 212;
return (
<article
key={i}
data-sss-card=""
data-in={i === 0 ? "true" : "false"}
className="group/card sticky isolate overflow-hidden rounded-[20px]"
style={{
height: cardHeight,
top: top + i * stackOffset,
marginBottom: i < count - 1 ? gap : 0,
transformOrigin: "50% 0%",
background: "linear-gradient(180deg, #16181d 0%, #0c0d10 100%)",
border: "1px solid rgba(255,255,255,0.09)",
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.07), 0 -14px 28px -12px rgba(0,0,0,0.55), 0 18px 40px -22px rgba(0,0,0,0.6)",
color: "#fff",
}}
>
<div aria-hidden="true" className="pointer-events-none absolute inset-0" style={{ background: `radial-gradient(70% 95% at 0% 0%, hsl(${hue} 90% 60% / 0.17), transparent 70%)` }} />
<div className="relative flex h-full items-stretch gap-4 p-5 @md:p-7">
<div className="flex min-w-0 flex-1 flex-col justify-between">
<div className="flex items-center gap-3 text-[11px] leading-none tracking-[0.18em]" style={{ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace" }}>
<span style={{ color: `hsl(${hue} 92% 76%)` }}>{String(i + 1).padStart(2, "0")}</span>
<span className="h-px w-8" style={{ background: "rgba(255,255,255,0.16)" }} />
<span style={{ color: "rgba(255,255,255,0.4)" }}>{String(count).padStart(2, "0")}</span>
</div>
<div>
<h3 className="m-0 text-[22px] font-semibold leading-[1.05] tracking-[-0.02em] @md:text-[30px]">{card.title}</h3>
<p className="m-0 mt-2 max-w-[34ch] text-[13px] leading-snug @md:text-[15px]" style={{ color: "rgba(255,255,255,0.6)" }}>
{card.body}
</p>
</div>
</div>
<div aria-hidden="true" className="hidden w-[38%] max-w-[220px] shrink-0 self-center @md:block">
<Art kind={i % 4} hue={hue} />
</div>
<div aria-hidden="true" className="absolute right-4 top-4 w-[72px] @md:hidden">
<Art kind={i % 4} hue={hue} />
</div>
</div>
<div aria-hidden="true" data-sss-dim="" className="pointer-events-none absolute inset-0 z-10 bg-black" style={{ opacity: 0 }} />
</article>
);
})}
<div ref={spacerRef} aria-hidden="true" style={{ height: 0 }} />
</div>
{showIndex && count > 1 && (
<nav aria-label="Stack progress" className="sticky self-start" style={{ top }}>
<div className="relative">
<span aria-hidden="true" className="absolute right-[19.5px] w-px" style={{ top: 20, bottom: 20, background: "currentColor", opacity: 0.16 }} />
<span
ref={fillRef}
aria-hidden="true"
className="absolute right-[19.5px] w-px origin-top"
style={{ top: 20, bottom: 20, background: "currentColor", opacity: 0.85, transform: "scaleY(0)" }}
/>
<ol className="relative m-0 flex list-none flex-col items-end p-0">
{cards.map((card, i) => {
const on = i <= active;
return (
<li key={i} className="m-0 p-0">
<button
type="button"
aria-label={`Go to card ${i + 1}: ${card.title}`}
aria-current={i === active ? "step" : undefined}
onClick={() => goRef.current(i)}
className="relative flex h-10 cursor-pointer items-center justify-end rounded-md border-0 bg-transparent p-0 text-inherit focus-visible:!outline-2 focus-visible:!outline-offset-[-3px] focus-visible:!outline-current"
>
<span
className="mr-1 hidden text-[11px] uppercase tracking-[0.18em] transition-opacity duration-200 @md:inline"
style={{ opacity: i === active ? 0.95 : 0.4 }}
>
{card.title}
</span>
<span className="relative grid h-10 w-10 place-items-center">
<span
className="block rounded-full bg-current transition-[transform,opacity] duration-300 motion-reduce:transition-none"
style={{ width: 8, height: 8, opacity: on ? 1 : 0.3, transform: i === active ? "scale(1.5)" : "scale(1)" }}
/>
</span>
</button>
</li>
);
})}
</ol>
</div>
</nav>
)}
</div>
);
return (
<div className={`@container relative ${className}`} style={{ width: `min(100%, ${width})` }}>
{own ? (
<div
ref={boxRef}
className="relative overflow-y-auto overscroll-contain rounded-2xl"
style={{ height, border: "1px solid rgba(128,128,128,0.28)" }}
>
{body}
</div>
) : (
body
)}
</div>
);
}
Free. Pro unlocks everything, from $27/month.
Details
About this pattern
A column of dark cards where each one pins near the top as you scroll and the next slides over it. Every buried card shrinks by scaleStep for each card piled on it (scaled from its top edge, so the pile tucks neatly) and darkens a little, which gives the stack real depth. Each card peeks stackOffset pixels above the one on top of it and carries a title, a line of copy and a small line illustration that draws itself in when the card arrives. A progress index sits beside the stack: dots that fill as you pass each card, a rail that grows with overall progress, and labels on wider layouts; click or tap a dot and the scroll glides to that card. The maths works on position: sticky plus one rAF-coalesced scroll listener that reads the track position and writes transforms straight to the DOM, so React never re-renders while you scroll. With no scroller prop it renders its own 288 by 420 scroll box (set height, width or className to resize it); pass scroller set to “window” to follow the page scroll, or the ref of your own overflow-y-auto element. Everything is measured in layout pixels, so it stays correct inside a transform-scaled preview.
Curator’s note
Rebuilt in October without framer-motion: the scroll maths is a few lines of hand-written code, so the component is React and Tailwind only and runs in the page or inside any scroll box you point it at.

