Card Stack

Cards that pin one behind another as the page scrolls, each settling behind the next.

Loading preview

Installation

npx shadcn@latest add @loomui/card-stack

Usage

import { CardStack, CardStackItem } from "@/components/ui/card-stack"
<CardStack>
  <CardStackItem>
    <article className="rounded-xl border p-6">Cut</article>
  </CardStackItem>
  <CardStackItem>
    <article className="rounded-xl border p-6">Pin</article>
  </CardStackItem>
</CardStack>

Each card pins a little lower than the one before it. A covered card keeps a strip of itself on screen, so the pile reads as a pile rather than one card that keeps changing its mind.

The scroll distance comes from the cards themselves. A card 320 pixels tall gives 320 pixels of travel before the next one covers it. Pace the section by sizing the cards, not by tuning a number.

How a card knows it is buried

A pinned card stops moving, so it cannot measure its own progress. It is measured from the card after it. How far that card has travelled to its own pin is exactly how buried this one is. The last card has nothing above it and never shrinks.

That progress is written to each card as --card-progress, a number from 0 to 1, and the scale and the fade are calc() on top of it. Nothing is animated frame by frame in JavaScript and no card re-renders while you scroll.

Depth

<CardStack offset={120} peek={32} scale={0.04} dim={0.2}>

offset is where the first card pins, measured from the top of the scrolling box. Leave room for a sticky header if you have one.

peek is how much lower each card pins than the last, which is the strip of the covered card that stays visible. Around 10 to 20 pixels is enough to see that there is something underneath. Push it past about 40 and the pile stops looking stacked and starts looking like a staircase.

dim is how much a fully covered card fades. It is deliberately slight. The card behind is context, not competition, and one that fades far enough to read the card in front through it has stopped looking like paper.

scale shrinks a covered card, and it is off by default on purpose. A shrink only reads as depth if peek is large enough to show a real strip of the card behind it. At a 10 or 15 pixel peek all you see is the top edge. An edge narrower than the card in front of it reads as a misalignment, not as distance. Raise peek to 30 or more first, then add a scale of around 0.04, and check the two together.

Progress is smoothstepped before it is written out. A card starts and stops being buried gently instead of shrinking at a flat rate the whole way down.

The last card

<CardStack tail={320}>

tail is how long the last card holds its pin once it lands, in pixels of scroll. Without it the stack ends the moment the last card arrives, and every pinned card gets dragged out of the panel on the last few pixels of scroll.

The tail is a real element in the flow rather than padding on the wrapper, and it has to be. A sticky box is held inside its containing block's content box. Padding on the stack buys scroll distance without extending how long the cards stay pinned. The cards reach the end of that range and get pushed, which looks exactly like the last card overshooting the line the others stopped on.

Inside a panel

The stack measures itself against the nearest scrolling ancestor, not always the page, because that is what position: sticky does too. A stack inside a dialog, a drawer or a scrolling panel works without being told which box it is in.

Props

CardStack

PropTypeDefaultDescription
offsetnumber96Pixels from the top of the scrollport to the pin.
peeknumber14Pixels lower each card pins than the last.
scalenumber0How much a fully covered card shrinks.
dimnumber0.12How much a fully covered card fades, from 0 to 1.
tailnumber200Pixels the last card stays pinned for once it lands.
disabledbooleanfalsePin the cards at full size and opacity.
classNamestringnoneMerged onto the wrapper.

CardStackItem

Takes any <div> prop. Put the card's border, padding and fill on the element inside it, not on the item. That keeps the sticky box the full width of the column.

Accessibility

Nothing is hidden or unmounted as the stack scrolls, so every card is in the document, in order, whatever is on top. Under prefers-reduced-motion the cards still pin, which is layout, but they hold their size and opacity instead of shrinking under each other.