A four-step explainer where the cards pile up as you scroll. Each card sticks near the top of the frame, a little lower than the one before, so the edges of earlier cards stay visible. As the next card rises over it, the covered card shrinks slightly from its top edge, which gives the pile depth. After the last card there is a short closing block with a button. The demo lives in its own scrolling frame and scrolls itself once so the effect shows in a preview; any touch, wheel or key press hands control back. Cards keep the same height on phones with tighter padding.
// Adapted from Animata (https://animata.design/docs/scroll/stacked-sections)
// Copyright (c) Animata. MIT License: https://we.inc/components/licenses
import { useEffect, useRef } from "react";
const steps = [
{ title: "Pick your roast", body: "Light and floral, dark and syrupy, or let us choose. Change your mind whenever you like.", meta: "Takes two minutes", card: "bg-amber-200", ink: "text-amber-950" },
{ title: "We roast on Monday", body: "Your beans go into the drum in small batches at our roastery in Bristol, never ahead of the order.", meta: "12 kg batches", card: "bg-rose-200", ink: "text-rose-950" },
{ title: "Posted on Tuesday", body: "A flat, letterbox-sized pouch leaves the next morning, so nobody has to wait in for it.", meta: "Free delivery", card: "bg-sky-200", ink: "text-sky-950" },
{ title: "Brew it your way", body: "Each bag comes with a recipe card for filter, espresso and cafetiere, tested on ordinary kit.", meta: "Three recipes a bag", card: "bg-lime-200", ink: "text-lime-950" },
];
const STACK_OFFSET = 22; // px of each covered card left peeking above the next
const SCALE_STEP = 0.05; // how much smaller each deeper card ends up
const DEMO_SECONDS = 10;
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
export default function StackedStickyCardsThatScaleBackAsYouScroll() {
const scrollerRef = useRef<HTMLDivElement>(null);
const cardRefs = useRef<(HTMLLIElement | null)[]>([]);
const contentRefs = useRef<(HTMLDivElement | null)[]>([]);
const total = steps.length;
// Scale each card's content down as the next card rises over it. Runs once per frame at most.
useEffect(() => {
const scroller = scrollerRef.current;
if (!scroller) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
let frame = 0;
const update = () => {
frame = 0;
const top = scroller.getBoundingClientRect().top;
for (let i = 0; i < total; i++) {
const card = cardRefs.current[i];
const content = contentRefs.current[i];
const next = cardRefs.current[i + 1];
if (!card || !content) continue;
if (!next) {
content.style.transform = "";
continue;
}
const endScale = 1 - SCALE_STEP * (total - 1 - i);
const pinnedTop = (i + 1) * STACK_OFFSET;
const offset = next.getBoundingClientRect().top - top - pinnedTop;
const distance = Math.max(card.offsetHeight - pinnedTop, 1);
const progress = clamp(1 - offset / distance, 0, 1);
content.style.transform = progress <= 0.001 ? "" : `scale(${1 + (endScale - 1) * progress})`;
}
};
const onScroll = () => {
if (!frame) frame = requestAnimationFrame(update);
};
update();
scroller.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll);
return () => {
scroller.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", onScroll);
if (frame) cancelAnimationFrame(frame);
};
}, [total]);
// Preview only: scroll the frame once, slowly, and stop for good on any input.
useEffect(() => {
const scroller = scrollerRef.current;
if (!scroller) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
let raf = 0;
let last = 0;
let t = 0;
let done = false;
const tick = (now: number) => {
if (done) return;
const dt = last ? Math.min(now - last, 50) : 0;
last = now;
t = Math.min(1, t + dt / (DEMO_SECONDS * 1000));
const eased = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
scroller.scrollTop = eased * (scroller.scrollHeight - scroller.clientHeight);
if (t < 1) raf = requestAnimationFrame(tick);
};
const io = new IntersectionObserver(
([entry]) => {
cancelAnimationFrame(raf);
last = 0;
if (entry.isIntersecting && !done) raf = requestAnimationFrame(tick);
},
{ threshold: 0.25 }
);
const stop = () => {
done = true;
cancelAnimationFrame(raf);
};
const inputs = ["wheel", "touchstart", "pointerdown", "keydown"];
io.observe(scroller);
inputs.forEach((name) => scroller.addEventListener(name, stop, { passive: true }));
return () => {
stop();
io.disconnect();
inputs.forEach((name) => scroller.removeEventListener(name, stop));
};
}, []);
return (
<section className="bg-stone-950 px-4 py-10 sm:px-6 sm:py-14">
<div className="mx-auto max-w-3xl">
<div
ref={scrollerRef}
tabIndex={0}
role="region"
aria-label="How the coffee subscription works, scrollable"
className="relative h-[600px] overflow-y-auto overscroll-contain rounded-3xl bg-stone-900 px-4 ring-1 ring-stone-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-300 sm:px-8"
>
<div className="px-2 pb-8 pt-12 text-center">
<p className="text-sm font-semibold uppercase tracking-widest text-amber-300">How it works</p>
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-white sm:text-4xl">From our drum to your door in four steps.</h2>
<p className="mt-3 text-sm text-stone-300">Scroll inside this frame.</p>
</div>
{/* The deck. Every card is sticky inside it, with a little more top padding than the one before. */}
<ol className="flex flex-col gap-3" style={{ paddingBottom: total * STACK_OFFSET }}>
{steps.map((step, index) => (
<li
key={step.title}
ref={(el) => {
cardRefs.current[index] = el;
}}
className="sticky top-0"
style={{ zIndex: index + 1, paddingTop: (index + 1) * STACK_OFFSET }}
>
<div
ref={(el) => {
contentRefs.current[index] = el;
}}
className={`flex h-[400px] origin-top flex-col rounded-3xl p-6 shadow-2xl shadow-black/40 will-change-transform sm:p-9 ${step.card} ${step.ink}`}
>
<div className="flex items-center justify-between">
<span className="text-sm font-bold uppercase tracking-widest">Step {index + 1}</span>
<span className="rounded-full bg-white/60 px-3 py-1 text-xs font-semibold">{step.meta}</span>
</div>
<h3 className="mt-auto text-4xl font-semibold tracking-tight sm:text-5xl">{step.title}</h3>
<p className="mt-3 max-w-md text-base leading-relaxed">{step.body}</p>
<div className="mt-6 flex gap-1.5" aria-hidden="true">
{steps.map((item, dot) => (
<span key={item.title} className={`h-1.5 rounded-full bg-current ${dot === index ? "w-8" : "w-1.5 opacity-30"}`} />
))}
</div>
</div>
</li>
))}
</ol>
<div className="flex h-56 flex-col items-center justify-center text-center">
<p className="text-lg font-semibold text-white">First bag is half price.</p>
<a
href="#"
className="mt-4 inline-flex h-11 items-center rounded-full bg-amber-300 px-6 text-sm font-bold text-stone-950 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-200 focus-visible:ring-offset-2 focus-visible:ring-offset-stone-900"
>
Start a subscription
</a>
</div>
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/stacked-sticky-cards-that-scale-back-as-you-scroll.json
Every list item is `position: sticky` with `top: 0` inside the same list. Each has more top padding than the last, so its visible card pins a little lower. Because they share one container, they all stay pinned until the list itself scrolls away.
Only the shrinking. On scroll it measures how far the next card is from its pinned position and scales the current card's inner block between 1 and its final size. The work is batched into one animation frame per scroll event.
Scaling the sticky item would change the box the browser uses to pin it, and the padding that sets its pinned offset would shrink too. Scaling a child leaves the layout untouched.
The list needs extra room at the bottom equal to the total offset, otherwise the last cards run out of scroll distance before reaching their pinned positions.
The usual cause is an ancestor with `overflow: hidden` or `overflow: auto` between the cards and the element that scrolls. Sticky pins to the nearest scrolling ancestor, so remove that overflow or make that ancestor the scroller.
Adapted from Stacked Sections in Animata, used under its open-source licence. Licences and credits
Related: Sticky Scroll Storytelling with Pinned Visual, Reading Progress Bar with Section Reveals, Numbered How It Works Steps in a Row.
More scroll effects or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.