Skip to content

Card stack

A pile of cards that fans on hover and cycles on click.

Status
Beta
Category
Data display
Adoption
Not used yet
import { CardStack } from "@oration/canon/components/card-stack";
packages/canon/src/components/card-stack.tsx
import { CardStack, cardStackInset } from "@oration/canon/components/card-stack";import { toast } from "@oration/canon/components/toast";import { SparklesIcon, XIcon } from "lucide-react";import * as React from "react";export function Hero() {    const updates = [        {            id: "matching",            date: "Sep 24",            title: "Remittances match themselves",        },        { id: "w9", date: "Sep 17", title: "W-9 reminders on a schedule" },        {            id: "slack",            date: "Sep 9",            title: "Approve payment runs from Slack",        },    ];    const [shown, setShown] = React.useState(true);    const peek = 6;    if (!shown) {        return (            <button                type="button"                onClick={() => setShown(true)}                className="text-13 text-muted-foreground underline-offset-3 hover:text-foreground hover:underline"            >                Show what's new again            </button>        );    }    return (        <div className="relative w-60">            <CardStack                items={updates.map((update) => ({                    id: update.id,                    content: (                        <div className="flex flex-col gap-0.5 px-2.5 py-2 pr-8">                            <span className="flex items-center gap-1.5 text-2xs/4 text-muted-foreground">                                <SparklesIcon                                    aria-hidden="true"                                    className="size-3"                                />                                What's new                                <span className="ml-auto tabular-nums">                                    {update.date}                                </span>                            </span>                            <span className="truncate text-13/5 font-medium text-foreground">                                {update.title}                            </span>                        </div>                    ),                }))}                peek={peek}                label={`What's new: ${updates[0]?.title}, ${updates[0]?.date}. 3 unread.`}                onSelect={(index) =>                    toast.add({                        title: "What's new",                        description: `Opened at ${updates[index]?.title}.`,                    })                }            />            <button                type="button"                aria-label="Dismiss what's new"                onClick={() => setShown(false)}                style={{ top: cardStackInset(updates.length, peek) + 6 }}                className="absolute right-1.5 z-10 flex size-5 items-center justify-center rounded-[5px] text-muted-foreground outline-none transition-colors duration-150 after:absolute after:-inset-1.5 hover:bg-foreground/5 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50"            >                <XIcon aria-hidden="true" className="size-3.5" />            </button>        </div>    );}

Usage#

Card stack draws a pile of cards: the first item in front and up to two more peeking out above it. Hovering or focusing the pile fans it open a little, and clicking it either opens the front item or cycles to the next. The product uses it for the what's new cards at the bottom of the sidebar and for agent templates, where the pile says "there's more than one of these" in the space of one card. The whole pile is a single button, so the mistake to avoid is putting links or buttons inside a card: they can't be reached, and the markup is invalid.

When to use

  • To show that several items of the same kind are waiting, in the space of one, such as unread updates or held remittances.
  • For a template or preset that bundles several parts, where the back cards hint at what's included.
  • When one click should open the front item, or step through the pile.

When not to use

  • For items people need to compare or act on one by one. Show them as a list. Use Item
  • For slides browsed in order with previous and next controls. Use Carousel
  • For pages read in order inside a modal. Use Stacked dialog
  • For a single card with actions inside it. Use Card

The Hairline-and-Lift Rule

Every card in the pile is Card White with the hairline lift and 12px corners. No borders, and no heavier shadow on the front card; depth comes from the offset and scale.

The Tint Well Rule

Don't nest a card stack inside a card as a second bordered card. Inside a card, use a tint well or a list.

Anatomy#

Held remittancesNorthwind Freight, 3 waiting
  1. Front card. The first item, full size, with its content visible. Its text is the pile's accessible name unless label is set.
  2. Back cards. Up to max - 1 more cards behind it, each peek px higher and 5% smaller. Their content is hidden; only the shells show.
  3. Peek space. Top padding of (count - 1) × peek px, or twice that when the fan is on, so the open pile stays inside its box.
  4. Focus ring. On keyboard focus, a 2px Focus Indigo ring at 50% on the front card.

Examples#

Cycling

Without onSelect, a click sends the front card to the back. Hover or tab to it to see the fan.

import { CardStack } from "@oration/canon/components/card-stack";import { FileTextIcon } from "lucide-react";export function Cycling() {    const remittances = [        { id: "r1", supplier: "Northwind Freight", amount: "$35,710.00" },        { id: "r2", supplier: "Halcyon", amount: "$9,612.50" },        { id: "r3", supplier: "Orchard Street", amount: "$4,120.00" },        { id: "r4", supplier: "Pioneer Metals", amount: "$41,200.00" },    ];    return (        <div className="w-64">            <CardStack                label="Unmatched remittances. Click to see the next one."                items={remittances.map((r) => ({                    id: r.id,                    content: (                        <div className="flex items-center gap-2 px-3 py-2.5">                            <FileTextIcon                                aria-hidden="true"                                className="size-4 shrink-0 text-muted-foreground"                            />                            <span className="min-w-0 flex-1 truncate text-13 font-medium">                                {r.supplier}                            </span>                            <span className="text-13 tabular-nums">                                {r.amount}                            </span>                        </div>                    ),                }))}            />        </div>    );}

Peek and max

max caps the cards drawn, front included; peek is how far each back card shows, in px. The pile reserves the space above itself, so it never overlaps what's above.

max 2, peek 6
max 3, peek 6
max 4, peek 8
import { CardStack } from "@oration/canon/components/card-stack";import { toast } from "@oration/canon/components/toast";import { CalendarClockIcon } from "lucide-react";export function PeekAndMax() {    const runs = [        { id: "pr-0412", title: "PR-0412", detail: "Friday, Oct 2" },        { id: "pr-0413", title: "PR-0413", detail: "Friday, Oct 9" },        { id: "pr-0414", title: "PR-0414", detail: "Friday, Oct 16" },        { id: "pr-0415", title: "PR-0415", detail: "Friday, Oct 23" },    ];    const variants = [        { label: "max 2, peek 6", max: 2, peek: 6 },        { label: "max 3, peek 6", max: 3, peek: 6 },        { label: "max 4, peek 8", max: 4, peek: 8 },    ];    return (        <div className="grid w-full max-w-2xl gap-6 sm:grid-cols-3">            {variants.map((variant) => (                <div key={variant.label} className="flex flex-col gap-2">                    <span className="text-xs text-muted-foreground">                        {variant.label}                    </span>                    <CardStack                        max={variant.max}                        peek={variant.peek}                        label={`Upcoming payment runs, ${variant.label}`}                        onSelect={(index) =>                            toast.add({ title: `Opened ${runs[index]?.title}` })                        }                        items={runs.map((run) => ({                            id: run.id,                            content: (                                <div className="flex items-center gap-2 px-3 py-2.5">                                    <CalendarClockIcon                                        aria-hidden="true"                                        className="size-4 shrink-0 text-muted-foreground"                                    />                                    <span className="font-mono text-xs">                                        {run.title}                                    </span>                                    <span className="ml-auto text-13 text-muted-foreground">                                        {run.detail}                                    </span>                                </div>                            ),                        }))}                    />                </div>            ))}        </div>    );}

Without the fan

fan={false} keeps the pile still on hover and focus, for a dense list where moving cards would distract.

import { CardStack } from "@oration/canon/components/card-stack";import { toast } from "@oration/canon/components/toast";export function NoFan() {    return (        <div className="w-64">            <CardStack                fan={false}                label="W-9s waiting for review: 5. Open the queue."                onSelect={() => toast.add({ title: "Opened W-9 review" })}                items={[                    "Halcyon",                    "Orchard Street",                    "Bayline Packaging",                    "Greenway Fleet",                    "Kestrel Office Supply",                ].map((supplier) => ({                    id: supplier,                    content: (                        <div className="flex flex-col gap-0.5 px-3 py-2.5">                            <span className="text-xs text-muted-foreground">                                W-9 to review                            </span>                            <span className="text-13 font-medium">                                {supplier}                            </span>                        </div>                    ),                }))}            />        </div>    );}

Templates

The agent templates pattern: the front card names the template and the back cards are its parts. cardClassName gives every card the same height, and label says what clicking does.

import { CardStack } from "@oration/canon/components/card-stack";import { toast } from "@oration/canon/components/toast";export function Templates() {    const templates = [        {            id: "weekly",            name: "Weekly payment run",            parts: ["Approval rule", "Bank file", "Remittance email"],        },        {            id: "w9",            name: "W-9 collection",            parts: ["Reminder cadence", "Supplier email"],        },        {            id: "early",            name: "Early-pay discounts",            parts: ["Discount rule", "Cash floor", "Approver"],        },    ];    return (        <div className="grid w-full max-w-2xl gap-4 sm:grid-cols-3">            {templates.map((template) => (                <CardStack                    key={template.id}                    peek={7}                    label={`Create from template: ${template.name}`}                    onSelect={() =>                        toast.add({                            title: `${template.name} created`,                            description: `Includes ${template.parts.join(", ").toLowerCase()}.`,                        })                    }                    cardClassName="min-h-24"                    items={[                        {                            id: `${template.id}-face`,                            content: (                                <div className="flex flex-col gap-1 p-3">                                    <span className="text-13 font-medium">                                        {template.name}                                    </span>                                    <span className="text-xs text-muted-foreground">                                        {template.parts.length} parts                                    </span>                                </div>                            ),                        },                        ...template.parts.map((part) => ({                            id: `${template.id}-${part}`,                            content: <div className="p-3 text-13">{part}</div>,                        })),                    ]}                />            ))}        </div>    );}

States#

States
StateTreatment
RestBack cards offset by peek px per step and scaled 5% per step.
FannedOn pointer hover (not touch) or keyboard focus: each back card rises to twice its rest offset and keeps its rest scale. No tilt, and the front card stays put. The moderate spring, 160ms with no bounce; each card starts 40ms after the one in front, and closing drops that delay.
Focus visibleFans the pile and rings the front card.
CyclingWithout onSelect, a click moves the front card to the back and the next one forward.
Single itemOne card, no peek space, nothing to cycle.
EmptyRenders nothing when items is empty.

Behavior#

  • The pile is one <button type="button">. Click, Enter or Space calls onSelect with the front item's index in items, or cycles the pile when there's no onSelect.
  • Fanning follows hover for mouse and pen, and :focus-visible for the keyboard, so tapping on a phone never fans it.
  • items can be plain nodes or { id, content }. Use ids when the list changes, so each card keeps its identity as it moves.
  • Only max cards render (3 by default). When cycling, the order wraps.
  • Put controls that act on the pile, such as a dismiss button, outside it as a sibling and position them over it, as the sidebar's what's new cards do.
  • Under the app's MotionConfig reducedMotion="user" the cards move to their positions without animating.

Do and don't#

Do. Keep the front card to a line or two of text, and put actions beside the pile.
Remittances match themselvesRead
Don't. Put a button or link inside a card. It's nested in the pile's button, so it can't be focused and clicks go to the pile.
Do. Give the pile a label that says what clicking does and how many are waiting: What's new: 3 unread updates. Open.
Don't. Rely on the peeking cards to say there are more. Screen readers only hear the front card.

Content#

  • Front card: a short caption (What's new, a date) over one line of title text, truncated.
  • label: what it is, what's in front and the count. Held remittances: Northwind Freight, 3 waiting.
  • Back cards show no text, so don't put anything there that people need.

Accessibility#

  • The pile is a native button. Back cards are aria-hidden and ignore the pointer.
  • Without label, the accessible name is the front card's text. Pass label when the text alone doesn't say what clicking does.
  • Fanning is decorative; nothing is announced when it fans.
  • Don't nest interactive elements inside items.
  • The focus ring is on the front card, so it stays visible as the pile fans.
Keyboard interactions
KeysAction
TabFocuses the pile and fans it.
EnterOpens the front item, or cycles the pile.
SpaceThe same as Enter.

Design tokens#

Design tokens
TokenUsed for
--cardCard fill
--card-foregroundCard text
shadow-borderEach card's hairline lift
--radius-xl12px corners
--ringFocus ring at 50%
spring.moderateFan and cycle motion, 160ms, no bounce

API reference#

CardStack

The pile, rendered as one button. Takes no other props.

Props of CardStack
PropTypeDefaultDescription
itemsRequiredCardStackItem[]No defaultThe cards, front first. Each is a node or { id: string; content: ReactNode }.
onSelect(index: number) => voidNo defaultCalled with the front item's index on click. Without it, clicking cycles the pile.
fanbooleantrueSpread the pile on hover and keyboard focus.
maxnumber3Cards shown, front included.
peeknumber6How far each back card shows above the one in front, in px.
labelstringNo defaultAccessible name. Defaults to the front card's text.
classNamestringNo defaultMerged onto the button.
cardClassNamestringNo defaultMerged onto every card, such as a fixed height.

CardStackItem

React.ReactNode | { id: string; content: React.ReactNode }.

No props of its own.

Known gaps#

Where the implementation and the system disagree today. Follow the system, not the gap.

The focus ring is 2px at 50%. Elsewhere the suite's focus ring is 3px, at 40% on buttons and 50% on fields.

Interactive cards in DESIGN.md take the hairline lift, hover on hover. The front card keeps the resting shadow; only the fan shows hover.

Back cards render their full content and hide it with opacity-0, so every visible item's content mounts even though only the front card shows.

cursor-pointer is set on the pile; other buttons in the suite keep the default cursor.