ComponentsCardStackAnimated
CardStackAnimated
A quiet, directional handoff for stories worth reading. Optional touch navigation, with the same accessible Card Stack foundation.
On this page
Install component source from the public registry, or use the local workspace package. Follow the setup guide first. The npm package is not published yet.
View registry files- Motion for React
(motion)Animations, transitions, and reduced-motion handling.
Shared foundation: React, clsx for conditional classes, tailwind-merge for resolving utility-class conflicts. Styles use Tailwind CSS and Dethink CSS variables.
Based on this component’s source and registry composition. Libraries used only in showcase examples are excluded.
Import the component into your page or component file.
import { Card, CardStackAnimated } from "@dethink/components";Move through findings at your own pace. No autoplay, no background movement, and explicit controls throughout.
An evidence-led briefing
Each finding separates the takeaway, its evidence, and what remains uncertain. On touch screens, swipe horizontally or use the buttons.
Evidence notebook
01 · Activation
Four of six sample interviews mentioned uncertainty after creating a workspace.
02 · Trust
Three sample reviewers asked where a recommendation came from.
03 · Workflow
Two sample reviewers deferred a decision because they feared losing the item.
Show sourceexamples/card-stack/actionable.tsx
"use client";
import { useRef, useState } from "react";
import {
ArrowUpRight,
Bookmark,
Check,
Clock3,
Sparkles,
Undo2,
} from "lucide-react";
import {
Button,
Card,
CardContent,
CardHeader,
CardStackAnimated,
CardTitle,
} from "@dethink/components";
const findings = [
{
title: "Make the first step obvious",
category: "Activation",
summary:
"New members may need a clearer route from an empty workspace to their first useful result.",
evidence:
"Four of six sample interviews mentioned uncertainty after creating a workspace.",
uncertainty:
"Small, fictional sample. Validate with real onboarding sessions.",
action: "Prototype a first-project checklist",
confidence: "Directional signal",
},
{
title: "Keep the source in sight",
category: "Trust",
summary:
"A concise answer becomes more useful when readers can inspect the evidence behind it.",
evidence: "Three sample reviewers asked where a recommendation came from.",
uncertainty:
"Interview feedback does not establish whether citations improve task success.",
action: "Test an evidence drawer",
confidence: "Needs validation",
},
{
title: "Let decisions be reversible",
category: "Workflow",
summary:
"A lightweight undo can make a review queue feel safer without adding a confirmation to every action.",
evidence:
"Two sample reviewers deferred a decision because they feared losing the item.",
uncertainty:
"Validate recovery expectations before applying this to destructive actions.",
action: "Test save, defer, and undo",
confidence: "Working hypothesis",
},
];
export function CardStackBriefing() {
const evidence = useRef<HTMLDetailsElement>(null);
return (
<div className="w-full space-y-6">
<div className="flex items-center justify-between gap-3 text-xs">
<span className="text-primary flex items-center gap-2 font-medium">
<Sparkles className="size-4" aria-hidden="true" /> Research briefing
</span>
<span className="text-muted-foreground">
Fictional sample · 3 findings
</span>
</div>
<CardStackAnimated
swipe
aria-label="Research briefing"
getCardLabel={(index) => findings[index]?.title ?? ""}
>
{findings.map((finding, index) => (
<Card key={finding.title} className="overflow-hidden">
<div
className="bg-primary/10 border-border flex h-24 items-end justify-between border-b px-6 pb-4"
aria-hidden="true"
>
<span className="text-primary/40 text-5xl font-light tracking-tighter">
0{index + 1}
</span>
<span className="text-primary text-xs font-medium tracking-widest uppercase">
{finding.category}
</span>
</div>
<CardHeader>
<p className="text-muted-foreground text-xs">
{finding.confidence}
</p>
<CardTitle>{finding.title}</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-sm leading-6">
<p>{finding.summary}</p>
<a
href={`#briefing-evidence-${index}`}
onClick={() => {
if (evidence.current) evidence.current.open = true;
}}
className="text-primary inline-flex items-center gap-1 underline underline-offset-4"
>
Inspect sample evidence{" "}
<ArrowUpRight className="size-3.5" aria-hidden="true" />
</a>
<p className="text-muted-foreground border-border border-t pt-3 text-xs leading-5">
{finding.uncertainty}
</p>
</CardContent>
</Card>
))}
</CardStackAnimated>
<details className="border-border rounded-lg border p-4 text-sm" open>
<summary className="cursor-pointer font-medium">
Evidence notebook
</summary>
<div className="mt-4 space-y-4">
{findings.map((finding, index) => (
<section
key={finding.title}
id={`briefing-evidence-${index}`}
tabIndex={-1}
className="scroll-mt-24"
>
<h4 className="font-medium">
0{index + 1} · {finding.category}
</h4>
<p className="text-muted-foreground mt-1 leading-6">
{finding.evidence}
</p>
</section>
))}
</div>
</details>
</div>
);
}
type Decision = { id: string; outcome: "saved" | "deferred" };
export function CardStackReviewQueue() {
const [notes, setNotes] = useState<Record<string, string>>({});
const [decisions, setDecisions] = useState<Decision[]>([]);
const [announcement, setAnnouncement] = useState("");
const completion = useRef<HTMLHeadingElement>(null);
const deck = useRef<HTMLDivElement>(null);
const pending = findings.filter(
(item) => !decisions.some((decision) => decision.id === item.title),
);
const saved = decisions.filter(
(decision) => decision.outcome === "saved",
).length;
function decide(id: string, outcome: Decision["outcome"]) {
setDecisions((current) => [...current, { id, outcome }]);
setAnnouncement(`${id} ${outcome}.`);
if (pending.length === 1)
requestAnimationFrame(() => completion.current?.focus());
}
function restore(all = false) {
setDecisions((current) => (all ? [] : current.slice(0, -1)));
setAnnouncement(all ? "Queue restarted." : "Last decision undone.");
requestAnimationFrame(() => deck.current?.focus());
}
return (
<div className="w-full space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3 text-sm">
<span className="text-muted-foreground">
{decisions.length} of {findings.length} reviewed
</span>
<Button
variant="ghost"
size="sm"
disabled={!decisions.length}
onClick={() => restore()}
leftIcon={<Undo2 className="size-3.5" aria-hidden="true" />}
>
Undo last
</Button>
</div>
<div
className="bg-muted h-1 overflow-hidden rounded-full"
aria-hidden="true"
>
<div
className="bg-primary h-full motion-safe:transition-[width]"
style={{ width: `${(decisions.length / findings.length) * 100}%` }}
/>
</div>
{pending.length ? (
<CardStackAnimated
ref={deck}
aria-label="Review queue"
loop={false}
getCardLabel={(index) => pending[index]?.title ?? ""}
>
{pending.map((finding) => (
<Card key={finding.title}>
<CardHeader>
<p className="text-primary text-xs font-medium tracking-widest uppercase">
Next experiment · {finding.category}
</p>
<CardTitle>{finding.action}</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<p className="text-muted-foreground text-sm leading-6">
{finding.summary}
</p>
<label className="text-muted-foreground grid gap-2 text-xs">
Review note
<input
className="border-input bg-background text-foreground focus-visible:ring-ring min-w-0 rounded-md border px-3 py-2 text-sm focus-visible:ring-2"
placeholder="Add context for your team"
value={notes[finding.title] ?? ""}
onChange={(event) =>
setNotes((current) => ({
...current,
[finding.title]: event.target.value,
}))
}
/>
</label>
<div className="flex flex-wrap gap-2">
<Button
onClick={() => decide(finding.title, "saved")}
leftIcon={
<Bookmark className="size-4" aria-hidden="true" />
}
>
Save idea
</Button>
<Button
variant="outline"
onClick={() => decide(finding.title, "deferred")}
leftIcon={<Clock3 className="size-4" aria-hidden="true" />}
>
Defer
</Button>
</div>
</CardContent>
</Card>
))}
</CardStackAnimated>
) : (
<div className="border-border bg-muted/30 flex min-h-72 flex-col items-center justify-center gap-4 rounded-xl border p-6 text-center">
<span className="bg-primary/10 text-primary grid size-12 place-items-center rounded-full">
<Check aria-hidden="true" />
</span>
<h3 ref={completion} tabIndex={-1} className="text-xl font-semibold">
Review complete
</h3>
<p className="text-muted-foreground text-sm">
{saved} saved · {decisions.length - saved} deferred. All changes
stayed in this demo.
</p>
<Button variant="outline" onClick={() => restore(true)}>
Restart queue
</Button>
</div>
)}
<p className="text-muted-foreground text-xs leading-5">
Fictional ideas. Decisions are local and reversible. Swiping never saves
or defers an item.
</p>
<p className="sr-only" role="status">
{announcement}
</p>
</div>
);
}A reversible review queue
Save promising experiments or defer them, undo a decision, and finish with a clear summary. Gestures never make a decision for you.
Fictional ideas. Decisions are local and reversible. Swiping never saves or defers an item.
Show sourceexamples/card-stack/actionable.tsx
"use client";
import { useRef, useState } from "react";
import {
ArrowUpRight,
Bookmark,
Check,
Clock3,
Sparkles,
Undo2,
} from "lucide-react";
import {
Button,
Card,
CardContent,
CardHeader,
CardStackAnimated,
CardTitle,
} from "@dethink/components";
const findings = [
{
title: "Make the first step obvious",
category: "Activation",
summary:
"New members may need a clearer route from an empty workspace to their first useful result.",
evidence:
"Four of six sample interviews mentioned uncertainty after creating a workspace.",
uncertainty:
"Small, fictional sample. Validate with real onboarding sessions.",
action: "Prototype a first-project checklist",
confidence: "Directional signal",
},
{
title: "Keep the source in sight",
category: "Trust",
summary:
"A concise answer becomes more useful when readers can inspect the evidence behind it.",
evidence: "Three sample reviewers asked where a recommendation came from.",
uncertainty:
"Interview feedback does not establish whether citations improve task success.",
action: "Test an evidence drawer",
confidence: "Needs validation",
},
{
title: "Let decisions be reversible",
category: "Workflow",
summary:
"A lightweight undo can make a review queue feel safer without adding a confirmation to every action.",
evidence:
"Two sample reviewers deferred a decision because they feared losing the item.",
uncertainty:
"Validate recovery expectations before applying this to destructive actions.",
action: "Test save, defer, and undo",
confidence: "Working hypothesis",
},
];
export function CardStackBriefing() {
const evidence = useRef<HTMLDetailsElement>(null);
return (
<div className="w-full space-y-6">
<div className="flex items-center justify-between gap-3 text-xs">
<span className="text-primary flex items-center gap-2 font-medium">
<Sparkles className="size-4" aria-hidden="true" /> Research briefing
</span>
<span className="text-muted-foreground">
Fictional sample · 3 findings
</span>
</div>
<CardStackAnimated
swipe
aria-label="Research briefing"
getCardLabel={(index) => findings[index]?.title ?? ""}
>
{findings.map((finding, index) => (
<Card key={finding.title} className="overflow-hidden">
<div
className="bg-primary/10 border-border flex h-24 items-end justify-between border-b px-6 pb-4"
aria-hidden="true"
>
<span className="text-primary/40 text-5xl font-light tracking-tighter">
0{index + 1}
</span>
<span className="text-primary text-xs font-medium tracking-widest uppercase">
{finding.category}
</span>
</div>
<CardHeader>
<p className="text-muted-foreground text-xs">
{finding.confidence}
</p>
<CardTitle>{finding.title}</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-sm leading-6">
<p>{finding.summary}</p>
<a
href={`#briefing-evidence-${index}`}
onClick={() => {
if (evidence.current) evidence.current.open = true;
}}
className="text-primary inline-flex items-center gap-1 underline underline-offset-4"
>
Inspect sample evidence{" "}
<ArrowUpRight className="size-3.5" aria-hidden="true" />
</a>
<p className="text-muted-foreground border-border border-t pt-3 text-xs leading-5">
{finding.uncertainty}
</p>
</CardContent>
</Card>
))}
</CardStackAnimated>
<details className="border-border rounded-lg border p-4 text-sm" open>
<summary className="cursor-pointer font-medium">
Evidence notebook
</summary>
<div className="mt-4 space-y-4">
{findings.map((finding, index) => (
<section
key={finding.title}
id={`briefing-evidence-${index}`}
tabIndex={-1}
className="scroll-mt-24"
>
<h4 className="font-medium">
0{index + 1} · {finding.category}
</h4>
<p className="text-muted-foreground mt-1 leading-6">
{finding.evidence}
</p>
</section>
))}
</div>
</details>
</div>
);
}
type Decision = { id: string; outcome: "saved" | "deferred" };
export function CardStackReviewQueue() {
const [notes, setNotes] = useState<Record<string, string>>({});
const [decisions, setDecisions] = useState<Decision[]>([]);
const [announcement, setAnnouncement] = useState("");
const completion = useRef<HTMLHeadingElement>(null);
const deck = useRef<HTMLDivElement>(null);
const pending = findings.filter(
(item) => !decisions.some((decision) => decision.id === item.title),
);
const saved = decisions.filter(
(decision) => decision.outcome === "saved",
).length;
function decide(id: string, outcome: Decision["outcome"]) {
setDecisions((current) => [...current, { id, outcome }]);
setAnnouncement(`${id} ${outcome}.`);
if (pending.length === 1)
requestAnimationFrame(() => completion.current?.focus());
}
function restore(all = false) {
setDecisions((current) => (all ? [] : current.slice(0, -1)));
setAnnouncement(all ? "Queue restarted." : "Last decision undone.");
requestAnimationFrame(() => deck.current?.focus());
}
return (
<div className="w-full space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3 text-sm">
<span className="text-muted-foreground">
{decisions.length} of {findings.length} reviewed
</span>
<Button
variant="ghost"
size="sm"
disabled={!decisions.length}
onClick={() => restore()}
leftIcon={<Undo2 className="size-3.5" aria-hidden="true" />}
>
Undo last
</Button>
</div>
<div
className="bg-muted h-1 overflow-hidden rounded-full"
aria-hidden="true"
>
<div
className="bg-primary h-full motion-safe:transition-[width]"
style={{ width: `${(decisions.length / findings.length) * 100}%` }}
/>
</div>
{pending.length ? (
<CardStackAnimated
ref={deck}
aria-label="Review queue"
loop={false}
getCardLabel={(index) => pending[index]?.title ?? ""}
>
{pending.map((finding) => (
<Card key={finding.title}>
<CardHeader>
<p className="text-primary text-xs font-medium tracking-widest uppercase">
Next experiment · {finding.category}
</p>
<CardTitle>{finding.action}</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<p className="text-muted-foreground text-sm leading-6">
{finding.summary}
</p>
<label className="text-muted-foreground grid gap-2 text-xs">
Review note
<input
className="border-input bg-background text-foreground focus-visible:ring-ring min-w-0 rounded-md border px-3 py-2 text-sm focus-visible:ring-2"
placeholder="Add context for your team"
value={notes[finding.title] ?? ""}
onChange={(event) =>
setNotes((current) => ({
...current,
[finding.title]: event.target.value,
}))
}
/>
</label>
<div className="flex flex-wrap gap-2">
<Button
onClick={() => decide(finding.title, "saved")}
leftIcon={
<Bookmark className="size-4" aria-hidden="true" />
}
>
Save idea
</Button>
<Button
variant="outline"
onClick={() => decide(finding.title, "deferred")}
leftIcon={<Clock3 className="size-4" aria-hidden="true" />}
>
Defer
</Button>
</div>
</CardContent>
</Card>
))}
</CardStackAnimated>
) : (
<div className="border-border bg-muted/30 flex min-h-72 flex-col items-center justify-center gap-4 rounded-xl border p-6 text-center">
<span className="bg-primary/10 text-primary grid size-12 place-items-center rounded-full">
<Check aria-hidden="true" />
</span>
<h3 ref={completion} tabIndex={-1} className="text-xl font-semibold">
Review complete
</h3>
<p className="text-muted-foreground text-sm">
{saved} saved · {decisions.length - saved} deferred. All changes
stayed in this demo.
</p>
<Button variant="outline" onClick={() => restore(true)}>
Restart queue
</Button>
</div>
)}
<p className="text-muted-foreground text-xs leading-5">
Fictional ideas. Decisions are local and reversible. Swiping never saves
or defers an item.
</p>
<p className="sr-only" role="status">
{announcement}
</p>
</div>
);
}The base Card Stack remains CSS-only. Install this separate variant when directional movement helps readers follow a change.
All CardStack props apply. Direct Card children need stable keys when inserting or removing items. The wrapper forwards its ref to the deck’s focusable group. Controlled state remains authoritative, including during an animation.
Motion uses a brief transform and opacity handoff. Rapid input interrupts it; reduced-motion users get immediate selection with the same position announcements. There is no entrance animation or autoplay.
Set swipe to enable deliberate horizontal touch and pen gestures. Mouse dragging keeps native text selection. Vertical scrolling, pinch zoom, links, buttons, and fields retain their native behavior. Use data-card-stack-no-swipe on custom interactive regions. Cancellation and short gestures do not navigate.
Keep previous/next controls available. Focus the group and use Arrow, Home, or End; nested fields retain their keys. Direction follows RTL. When focused content is removed, focus returns to the deck; your completion view should receive focus when the deck itself is removed.
Cards stay mounted, including hidden neighbours. visibleCount bounds the visual deck, not memory use. Use a table or list for large collections or comparison. Tokens, density, and className follow the base component’s theming contract.
A drop-in optional variant. Existing CardStack consumers require no migration.
| Prop | What it does | Default |
|---|---|---|
swipeboolean | Opt-in horizontal touch/pen navigation. Does not approve or dismiss cards. | false |
getCardLabel(index: number) => string | Explicit card name for fan selectors and position announcements. Keep it short and meaningful; empty labels fall back to the card number. | Not set |
visibleCountnumber | Maximum visible cards including the active card, clamped to 1–9. Hidden cards stay mounted to preserve local state; this is not virtualization. | 5 |
childrenCard element | Card element[] | Direct Card children only. Anything else throws, so wrappers must merge onto a Card via asChild. | Not set |
mode"stack" | "open" | Layered deck with the active card on top, or a fanned arc that spreads the cards apart. | "stack" |
activeIndexnumber | Controlled index of the active card. Pair with onActiveIndexChange. | Not set |
defaultActiveIndexnumber | Initial active card for uncontrolled usage. | 0 |
onActiveIndexChange(index: number) => void | Fires with the normalized index whenever navigation changes the active card. | Not set |
loopboolean | Wrap from the last card back to the first. When false, controls disable at the ends. | true |
showControlsboolean | Show or hide the previous/next icon buttons below the deck in either mode. | stack mode with 2+ cards |
showPreviousControlboolean | Show or hide only the previous (back) icon button. Overrides showControls for that side. | showControls |
showNextControlboolean | Show or hide only the next icon button. Overrides showControls for that side. | showControls |
previousLabelstring | Accessible label for the built-in previous control. | "Show previous card" |
nextLabelstring | Accessible label for the built-in next control. | "Show next card" |
anglenumber | Per-card rotation in open mode, clamped between 0 and 30 degrees. | 15 |
stackOffsetnumber | Pixel offset applied per depth level in stack mode, clamped between 0 and 32. | 8 |
aria-label / aria-labelledbystring | Accessible name of the focusable group. Always pass one that describes the content. | "Card stack" |