Card stack
Review a deck one card at a time, with a throw and an undo.
gesturemotiontriage
Preview
Card stack preview
Live · keyboard ready
🌊
Lisbon
Seven hills, ocean light
Drag the top card, or use the left and right arrow keys to skip or shortlist it. Backspace undoes the last decision.
When to use
- One-by-one triage such as shortlisting, review queues, or onboarding picks.
- Decks where swiping left or right should be undoable.
When not to use
- Use a list or sortable-data-table when people compare items side by side.
- Use carousel to browse items without deciding.
- Use swipe-actions for quick actions on rows in a list.
shadcn CLI
Also installs (1)
- manicat-foundation
Manual install(+)
registry.jsonjson
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "card-stack",
"type": "registry:ui",
"title": "Card stack",
"description": "Review a deck one card at a time, with a throw and an undo.",
"categories": [
"data"
],
"dependencies": [
"lucide-react",
"motion"
],
"registryDependencies": [
"https://manicat.viraza.net/r/manicat-foundation.json"
],
"files": [
{
"path": "registry/components/card-stack/card-stack.tsx",
"type": "registry:component",
"target": "@components/manicat/card-stack/card-stack.tsx"
},
{
"path": "registry/components/card-stack/card-stack.module.css",
"type": "registry:component",
"target": "@components/manicat/card-stack/card-stack.module.css"
}
],
"docs": "Docs and live preview: https://uiarc.dev/components/card-stack",
"meta": {
"tier": "free",
"kind": "component",
"docs": "https://manicat.viraza.net/components/card-stack",
"markdown": "https://uiarc.dev/components/card-stack/markdown",
"tags": [
"gesture",
"motion",
"triage"
]
}
}Usage
Install the component, then copy this into your app. Change the content and the props to fit your screen.
example.tsxtsx
"use client";
import { CardStack } from "@/components/manicat/card-stack/card-stack";
type Destination = { name: string; tag: string; emoji: string };
const destinations: Destination[] = [
{ name: "Lisbon", tag: "Seven hills, ocean light", emoji: "🌊" },
{ name: "Kyoto", tag: "Temples and quiet lanes", emoji: "⛩️" },
{ name: "Reykjavik", tag: "Lava fields, hot springs", emoji: "🌋" },
{ name: "Auckland", tag: "Harbour and rainforest", emoji: "⛵" },
];
export function CardStackExample() {
return (
<CardStack
items={destinations}
getKey={(item) => item.name}
getLabel={(item) => item.name}
labels={{ left: "Skip", right: "Shortlist" }}
outcomes={{ left: "skipped", right: "shortlisted" }}
label="Trip ideas"
renderCard={(item) => (
<div className="flex aspect-[5/3] flex-col justify-between p-6">
<span className="text-4xl">{item.emoji}</span>
<div>
<p className="text-lg font-semibold">{item.name}</p>
<p className="text-sm text-(color:--text-muted)">{item.tag}</p>
</div>
</div>
)}
renderEmpty={(reset) => (
<div className="flex flex-col items-center gap-3 py-10 text-center">
<p className="text-sm text-(color:--text-muted)">Every idea was decided.</p>
<button type="button" className="text-sm font-medium text-(color:--foreground)" onClick={reset}>
Start over
</button>
</div>
)}
/>
);
}
Examples
Real screens rather than isolated props. Switch to Code to copy a whole example into your project.
Trip shortlist Swipe right to shortlist, left to skip; an empty state offers to start over.
Trip shortlist
Swipe right to shortlist, left to skip; an empty state offers to start over. preview
Live · keyboard ready
🌊
Lisbon
Seven hills, ocean light
Drag the top card, or use the left and right arrow keys to skip or shortlist it. Backspace undoes the last decision.
trip-shortlist-swipe-right-to-shortlist-left-to-skip-an-empty-state-offers-to-start-over-.tsxtsx
"use client";
import { CardStack } from "@/components/manicat/card-stack/card-stack";
const destinations = [
{ name: "Lisbon", tag: "Seven hills, ocean light", emoji: "🌊" },
{ name: "Kyoto", tag: "Temples and quiet lanes", emoji: "⛩️" },
{ name: "Reykjavik", tag: "Lava fields, hot springs", emoji: "🌋" },
];
export function CardStackTripsExample() {
return (
<CardStack
items={destinations}
getKey={(item) => item.name}
getLabel={(item) => item.name}
labels={{ left: "Skip", right: "Shortlist" }}
outcomes={{ left: "skipped", right: "shortlisted" }}
label="Trip ideas"
renderCard={(item) => (
<div className="flex aspect-[5/3] flex-col justify-between p-6">
<span className="text-4xl">{item.emoji}</span>
<div>
<p className="text-lg font-semibold">{item.name}</p>
<p className="text-sm text-(color:--text-muted)">{item.tag}</p>
</div>
</div>
)}
renderEmpty={(reset) => (
<div className="flex flex-col items-center gap-3 py-10 text-center">
<p className="text-sm text-(color:--text-muted)">Every idea was decided.</p>
<button type="button" className="text-sm font-medium" onClick={reset}>
Start over
</button>
</div>
)}
/>
);
}
Keyboard interactions
| Keys | Action |
|---|---|
| ArrowLeft / ArrowRight | Decides the top card left or right when the stack is focused. |
| Backspace / Cmd+Z / Ctrl+Z | Undoes the last decision. |
Accessibility
- The stage is a focusable role="group" with aria-roledescription="card stack" and a hidden hint describing drag and keys.
- Each card is labelled like "Lisbon, 1 of 3".
- Decisions, undo, and reset are announced in a polite status region.
- Buttons mirror every gesture, so swiping is never required.
Motion
- The top card follows the drag with tilt and stamps; a flick or far drag throws it off with its velocity.
- Cards beneath rise into place, and undo flies a card back from where it left.
- Reduced motion fades cards out and in at home without travel.
Responsive behavior
- The stage is min(100%, 20rem) wide; below 26rem the cards get shorter, and below 22.5rem Undo becomes icon-only.
- Cards use touch-action pan-y, so vertical swipes still scroll the page.
Performance
- Only the top four cards render in the stage, however long the deck.
- Drag and throw run on motion values without per-frame React updates.
Notes for AI
- Use for one-by-one triage: shortlisting, review queues, onboarding picks. Use a list or sortable-data-table when people compare items side by side.
- Persist decisions from onDecide and reverse them in onUndo.