Stacked Sections
A vertical deck primitive that pins each child as a sticky pane, leaving a thin strip of the one before visible above — a true stack of cards.
Installation
CLI
pnpm dlx shadcn@latest add https://animata.design/r/scroll/stacked-sections.json
Manual
Run the following command
It will create a new file called stacked-sections.tsx inside the components/animata/scroll directory.
mkdir -p components/animata/scroll && touch components/animata/scroll/stacked-sections.tsx components/animata/scroll/stacked-sections.cssPaste the code
@layer components {
[data-stacked-content][data-stacked-covered] {
transform-origin: 50% 0%;
}
}"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import "./stacked-sections.css";
export type StackedSectionsProps = {
/**
* One pane per direct child — sticky card > inner content (scale only).
* Siblings in one deck so every sticky shares the deck scroll range (stacking-cards pattern).
*/
children?: React.ReactNode;
/** Scale covered panes while the next card rises into pin. @default true */
withDramaEffect?: boolean;
/**
* Stagger between stacked panes (`padding-top` on each sticky card, 1-based index).
* Match peek strip height. @default 48
*/
stackOffset?: number;
/** Tailwind gap classes between cards (block `gap-*`). Pass `false` to disable. @default `gap-2` */
paneGap?: false | string;
/**
* In-flow spacer height after the last card so the final pane stays pinned through the outro scroll.
* @default `0px`
*/
scrollRunway?: string;
className?: string;
};
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
function getScrollParent(node: HTMLElement | null): HTMLElement | null {
let element = node?.parentElement ?? null;
while (element && element !== document.body && element !== document.documentElement) {
const overflowY = getComputedStyle(element).overflowY;
if (overflowY === "auto" || overflowY === "scroll") {
return element;
}
element = element.parentElement;
}
return null;
}
export default function StackedSections({
children,
withDramaEffect = true,
stackOffset = 48,
paneGap = "gap-2",
scrollRunway = "0px",
className,
}: StackedSectionsProps) {
const deckRef = React.useRef<HTMLDivElement>(null);
const cardRefs = React.useRef<(HTMLDivElement | null)[]>([]);
const contentRefs = React.useRef<(HTMLDivElement | null)[]>([]);
const items = React.Children.toArray(children);
const total = items.length;
cardRefs.current.length = total;
contentRefs.current.length = total;
const scaleAtDepth = React.useCallback(
(cardIndex: number) => {
const reverseIndex = total - (cardIndex - 1);
return 1.1 - 0.1 * reverseIndex;
},
[total],
);
// Scale on `.card__content` only. Each pane freezes once the next card has pinned.
React.useEffect(() => {
if (!withDramaEffect || total === 0) {
return;
}
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
return;
}
const deck = deckRef.current;
if (!deck) {
return;
}
const scroller = getScrollParent(deck);
let frame = 0;
const isNextCardPinned = (cardIndex: number, containerTop: number) => {
const nextCard = cardRefs.current[cardIndex + 1];
if (!nextCard) {
return false;
}
return (
nextCard.getBoundingClientRect().top - containerTop <= (cardIndex + 1) * stackOffset + 1
);
};
const update = () => {
frame = 0;
const containerTop = scroller ? scroller.getBoundingClientRect().top : 0;
for (let i = 0; i < total; i++) {
const card = cardRefs.current[i];
const content = contentRefs.current[i];
if (!card || !content) {
continue;
}
const endScale = scaleAtDepth(i + 1);
const covered = isNextCardPinned(i, containerTop);
if (covered) {
content.dataset.stackedCovered = "";
content.style.transform = `scale(${endScale})`;
continue;
}
delete content.dataset.stackedCovered;
const nextCard = cardRefs.current[i + 1];
if (!nextCard) {
content.style.transform = "";
continue;
}
const pinnedTop = (i + 1) * stackOffset;
const offset = nextCard.getBoundingClientRect().top - containerTop - pinnedTop;
const rowH = card.offsetHeight > 0 ? card.offsetHeight : 1;
const distance = Math.max(rowH - pinnedTop, 1);
const progress = clamp(1 - offset / distance, 0, 1);
const scale = 1 + (endScale - 1) * progress;
content.style.transform = progress <= 0.001 ? "" : `scale(${scale})`;
}
};
const onScroll = () => {
if (!frame) {
frame = requestAnimationFrame(update);
}
};
update();
const target: Window | HTMLElement = scroller ?? window;
target.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll);
return () => {
target.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", onScroll);
if (frame) {
cancelAnimationFrame(frame);
}
for (const content of contentRefs.current) {
if (content) {
delete content.dataset.stackedCovered;
content.style.transform = "";
}
}
};
}, [total, stackOffset, withDramaEffect, scaleAtDepth]);
if (total === 0) {
return null;
}
const gapClass = paneGap === false ? undefined : paneGap;
return (
<>
<div
ref={deckRef}
data-stacked-deck=""
className={cn("flex w-full flex-col", gapClass, className)}
style={
{
"--numcards": total,
"--stacked-top-offset": `${stackOffset}px`,
paddingBottom: `calc(${total} * ${stackOffset}px)`,
} as React.CSSProperties
}
>
{items.map((child, index) => {
const key =
React.isValidElement(child) && child.key != null ? child.key : `pane-${index}`;
const cardIndex = index + 1;
return (
<div
key={key}
ref={(el) => {
cardRefs.current[index] = el;
}}
data-stacked-card=""
className="sticky top-0 w-full"
style={
{
"--index": cardIndex,
zIndex: cardIndex,
paddingTop: `calc(${cardIndex} * var(--stacked-top-offset))`,
} as React.CSSProperties
}
>
<div
ref={(el) => {
contentRefs.current[index] = el;
}}
data-stacked-content=""
className="min-h-0 origin-[50%_0%]"
>
{child}
</div>
</div>
);
})}
{scrollRunway !== "0px" ? (
<div
aria-hidden
className="w-full shrink-0"
style={{ height: scrollRunway }}
data-stacked-runway=""
/>
) : null}
</div>
</>
);
}Usage
StackedSections is a thin primitive. Each direct child becomes one card in the deck; palette, copy, and layout are yours.
<StackedSections stackOffset={48}>
<MyFirstChapter />
<MySecondChapter />
<MyThirdChapter />
</StackedSections>How it works
This mirrors the CSS stacking-cards demo:
[data-stacked-deck] ← flex column; handoff padding + in-flow scrollRunway spacer
[data-stacked-card] ← position: sticky; top: 0; padding-top: index × offset
[data-stacked-content] ← scale while the next card rises; frozen once it has pinned
{your chapter}- Deck (
#cards). A single flex column — all cards are siblings so eachposition: stickyshares the deck as its scroll range (earlier cards stay pinned while later ones stack on top).padding-bottom: numcards × stackOffsethandles stack handoff; an in-flow[data-stacked-runway]spacer (height =scrollRunway) extends that range so the last pane does not scroll away early. - Card (
.card).position: sticky,top: 0, andpadding-top: calc(var(--index) × offset)so each pane pins at the same edge but peeks below the previous (--indexis 1-based, like the reference). - Content (
.card__content). Transform runs here so scroll height stays honest. While the next card rises into its pin, scale interpolates towardscale(calc(1.1 - 0.1 × reverse-index)). Once the next card has stuck, that pane's scale locks — it does not keep animating when you scroll through runway padding. - Why not deck-level scroll-driven CSS? Tying every card to one
view-timelineexit-crossingslice re-animated covered panes after they had already pinned. Per-pane freeze on cover matches the stacking-cards intent more reliably.
When to use
Use this for narrative pages with a handful of distinct chapters — a curated reading list, a multi-act feature story, an editorial release page, an "about" or manifesto split into themes. The deck-stacking gesture turns ordinary scrolling into a reveal where every previous chapter's strip remains visible as the next one pins on top.
When not to use
- For more than about five panes. A deeper deck makes the lower panes invisible for too long.
- When the panes aren't visually distinct — if every chapter is the same colour, the stacking gesture has nothing to dramatise.
- As a primary navigation pattern. Readers can't easily jump between panes; the deck is meant for linear scrolling.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | — | One card per direct child. Fragments are flattened. |
withDramaEffect | boolean | true | Scale covered panes on the inner content layer while the next card pins. Disable for sticky-only. |
stackOffset | number | 48 | Pixels per 1-based index for each card's |
paneGap | false | string | gap-2 | Tailwind |
scrollRunway | string | 0px | Height of the in-flow runway spacer after the last card. Use in boxed scrollers so the final pane stays pinned — e.g. |
className | string | — | Forwarded to the deck wrapper. |
Accessibility
- Use real headings (
<h2>) inside each pane — the primitive doesn't impose structure. - If a pane carries links, render them as
<a>elements with palette-tinted focus rings. - No scroll hijacking — a passive scroll listener drives the optional cover scale only.
prefers-reduced-motiondisables the scale animation; cards still pin.