Card stack
A pile of cards that fans on hover and cycles on click.
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
The Tint Well Rule
Anatomy#
- Front card. The first item, full size, with its content visible. Its text is the pile's accessible name unless
labelis set. - Back cards. Up to
max - 1more cards behind it, eachpeekpx higher and 5% smaller. Their content is hidden; only the shells show. - Peek space. Top padding of
(count - 1) × peekpx, or twice that when the fan is on, so the open pile stays inside its box. - 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.
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#
| State | Treatment |
|---|---|
| Rest | Back cards offset by peek px per step and scaled 5% per step. |
| Fanned | On 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 visible | Fans the pile and rings the front card. |
| Cycling | Without onSelect, a click moves the front card to the back and the next one forward. |
| Single item | One card, no peek space, nothing to cycle. |
| Empty | Renders nothing when items is empty. |
Behavior#
- The pile is one
<button type="button">. Click, Enter or Space callsonSelectwith the front item's index initems, or cycles the pile when there's noonSelect. - Fanning follows hover for mouse and pen, and
:focus-visiblefor the keyboard, so tapping on a phone never fans it. itemscan be plain nodes or{ id, content }. Use ids when the list changes, so each card keeps its identity as it moves.- Only
maxcards 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#
label that says what clicking does and how many are waiting: What's new: 3 unread updates. Open.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-hiddenand ignore the pointer. - Without
label, the accessible name is the front card's text. Passlabelwhen 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.
| Keys | Action |
|---|---|
| Tab | Focuses the pile and fans it. |
| Enter | Opens the front item, or cycles the pile. |
| Space | The same as Enter. |
Design tokens#
| Token | Used for |
|---|---|
--card | Card fill |
--card-foreground | Card text |
shadow-border | Each card's hairline lift |
--radius-xl | 12px corners |
--ring | Focus ring at 50% |
spring.moderate | Fan and cycle motion, 160ms, no bounce |
API reference#
CardStack
The pile, rendered as one button. Takes no other props.
| Prop | Type | Default | Description |
|---|---|---|---|
itemsRequired | CardStackItem[] | No default | The cards, front first. Each is a node or { id: string; content: ReactNode }. |
onSelect | (index: number) => void | No default | Called with the front item's index on click. Without it, clicking cycles the pile. |
fan | boolean | true | Spread the pile on hover and keyboard focus. |
max | number | 3 | Cards shown, front included. |
peek | number | 6 | How far each back card shows above the one in front, in px. |
label | string | No default | Accessible name. Defaults to the front card's text. |
className | string | No default | Merged onto the button. |
cardClassName | string | No default | Merged 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.