Overview
Scroll Stack pins each card with position: sticky at a slightly larger top offset than the one before, so the cards pile into a deck as you scroll. While the next card travels up into place, the cards beneath it shrink by a fixed step and darken in proportion to how far it has come. The effect is driven by one useScroll subscription (the window or any scrollable box passed as a container ref) that writes a single CSS variable per card, so scrolling never re-renders React. Without JavaScript it is still a working sticky stack, and with reduced motion the cards stack without scaling or dimming.
01Installation
Method 01 · Registry
CLI verifiedUses the shadcn CLI in a project initialised with shadcn init. Installs the file to components/ui/scroll-stack.tsx and adds its dependencies.
npx shadcn@latest add https://wancraft.my/r/scroll-stack.jsonMethod 02 · Copy and paste
- 1. Install the dependencies below.
- 2. Copy the source from the Code tab into
components/ui/scroll-stack.tsx. - 3. Import it and adjust props.
npm install motion- Dependencies
- motion
- Peer
- react ≥ 18, tailwindcss ≥ 4
- Added
- 13 Oct 2026
- Updated
- —
02Usage
The default configuration. Change properties above to generate a tailored example in the Usage tab.
import { ScrollStack, type ScrollStackItem } from "@/components/ui/scroll-stack";
export function ScrollStackExample() {
const items: ScrollStackItem[] = [
{ id: "brief", content: <article className="p-8">Brief</article> },
{ id: "sketch", content: <article className="p-8">Sketch</article> },
{ id: "build", content: <article className="p-8">Build</article> },
{ id: "ship", content: <article className="p-8">Ship</article> },
];
return (
<section className="mx-auto max-w-2xl py-24">
<ScrollStack items={items} />
</section>
);
}03Props
| Prop | Type | Default | Description |
|---|---|---|---|
| items | ScrollStackItem[] | — | { id: string; content: ReactNode }. Alternatively pass each card as a child. |
| children | ReactNode | — | Each child becomes one card when items is not given. |
| container | RefObject<HTMLElement | null> | — | Scrollable ancestor the stack scrolls inside. Defaults to the window. |
| top | number | 16 | Where the first card pins, measured from the top of the scroll area, in px. |
| stackOffset | number | 16 | Extra top offset per card so covered cards peek out, in px. |
| scaleStep | number | 0.04 | Scale removed from a card for each card that lands on it. |
| dim | boolean | true | Darken cards as they are covered. |
| gap | number | 32 | Space between cards before they stack, in px. |
| radius | number | 20 | Card corner radius, in px. |
| showIndex | boolean | true | Show a decorative 01 / 05 counter on each card. |
| accent | string | "#FF7448" | Counter marker colour. |
| cardClassName | string | — | Classes added to every card shell. |
| ...props | OlHTMLAttributes<HTMLOListElement> | — | Native attributes are forwarded to the <ol>. |
04Features
- 01Pure CSS sticky stacking with incremental top offsets: works before hydration and without JavaScript
- 02Covered cards scale down and dim in proportion to how far the next card has travelled
- 03One useScroll subscription writes a CSS variable per card, so scrolling never re-renders
- 04Works with the window or any scrollable container via a ref
- 05Geometry is measured from natural layout and re-measured with a ResizeObserver
- 06Items array or plain children
05Accessibility
- Rendered as an ordered list, so assistive technology announces the number of cards and each position.
- Scaling and dimming are purely visual; card content and reading order are unchanged.
- The 01 / 05 counter and the dimming layer are aria-hidden.
- With prefers-reduced-motion, cards still pin but never scale or dim.
06Limitations
- Covered cards are partly hidden by design; avoid placing interactive controls in the area a later card covers.
- When a container is used it should have no top padding, because sticky offsets are measured from the scroll area's edge.