Skip to content
WANCRAFT

B.68Cards & Containers

Scroll Stack

Cards that pin and pile up as you scroll, with covered cards easing back in scale and light.

NewReactMotionCSSTailwindintermediate

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 verified

Uses 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.json

Method 02 · Copy and paste

  1. 1. Install the dependencies below.
  2. 2. Copy the source from the Code tab into components/ui/scroll-stack.tsx.
  3. 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.

example.tsx
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

PropTypeDefaultDescription
itemsScrollStackItem[]—{ id: string; content: ReactNode }. Alternatively pass each card as a child.
childrenReactNode—Each child becomes one card when items is not given.
containerRefObject<HTMLElement | null>—Scrollable ancestor the stack scrolls inside. Defaults to the window.
topnumber16Where the first card pins, measured from the top of the scroll area, in px.
stackOffsetnumber16Extra top offset per card so covered cards peek out, in px.
scaleStepnumber0.04Scale removed from a card for each card that lands on it.
dimbooleantrueDarken cards as they are covered.
gapnumber32Space between cards before they stack, in px.
radiusnumber20Card corner radius, in px.
showIndexbooleantrueShow a decorative 01 / 05 counter on each card.
accentstring"#FF7448"Counter marker colour.
cardClassNamestring—Classes added to every card shell.
...propsOlHTMLAttributes<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
motion · scroll-linkedinput · scroll

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.