ComponentsCardStack
CardStack
A focused deck for release stories, customer proof, and a shortlist of ideas.
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 filesImplemented with React and browser APIs, without an additional behavior or rendering library.
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, CardStack } from "@dethink/components";Try the examples, then open the code to use them in your app. Focus a stack and use Arrow, Home, and End keys to navigate.
A bounded collection
Seven chapters, three visible cards. Names explain where navigation leads, and your notes stay intact when their cards leave the visible deck.
Show sourceexamples/card-stack/bounded.tsx
"use client";
import {
Card,
CardContent,
CardHeader,
CardStack,
CardTitle,
} from "@dethink/components";
const chapters = [
"Start with the question",
"Gather the evidence",
"Compare alternatives",
"Make the decision",
"Share the reasoning",
"Measure the outcome",
"Reflect and improve",
];
export function CardStackBounded() {
return (
<CardStack
aria-label="Research chapters"
mode="open"
angle={5}
visibleCount={3}
showControls
getCardLabel={(index) => chapters[index] ?? ""}
>
{chapters.map((chapter, index) => (
<Card key={chapter}>
<CardHeader>
<p className="text-primary text-xs font-medium">
CHAPTER {String(index + 1).padStart(2, "0")}
</p>
<CardTitle>{chapter}</CardTitle>
</CardHeader>
<CardContent>
<label className="text-muted-foreground grid gap-2 text-sm">
Your note
<input
className="border-input bg-background text-foreground focus-visible:ring-ring min-w-0 rounded-md border px-3 py-2 focus-visible:ring-2"
placeholder="What matters here?"
/>
</label>
</CardContent>
</Card>
))}
</CardStack>
);
}Release stories
Give each update a product preview, a clear next action, and a place in the deck. Navigation stays below the cards so narrow screens keep room for the story.

A calmer way to ship.
Sample release stories · live recipe previews
Show sourceexamples/card-stack/basic.tsx
"use client";
import { useState } from "react";
import Image from "next/image";
import { ArrowUpRight } from "lucide-react";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardStack,
CardTitle,
} from "@dethink/components";
const releases = [
{
version: "02.8",
title: "A calmer way to ship.",
category: "Release operations",
summary:
"One shared checklist. Clear ownership. Every detail ready for the next release.",
recipe: "release-readiness",
action: "Explore the release room",
},
{
version: "02.7",
title: "Make room for deep work.",
category: "AI workspace",
summary:
"Bring research, conversations, and your next decision into one focused workspace.",
recipe: "ai-workspace",
action: "Explore the workspace",
},
{
version: "02.6",
title: "Your day, thoughtfully arranged.",
category: "Scheduling",
summary:
"Find the right time, see availability, and turn a busy calendar into a clear plan.",
recipe: "scheduler-and-booking",
action: "Explore scheduling",
},
];
export function CardStackBasic() {
const [activeIndex, setActiveIndex] = useState(0);
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-3 px-2 text-xs">
<span className="text-muted-foreground font-medium tracking-widest uppercase">
From the release room
</span>
<span className="text-muted-foreground tabular-nums">
{String(activeIndex + 1).padStart(2, "0")} / 03
</span>
</div>
<CardStack
className="[--card-stack-max-width:28rem]"
aria-label="Release stories"
getCardLabel={(index) => releases[index]?.title ?? ""}
activeIndex={activeIndex}
onActiveIndexChange={setActiveIndex}
>
{releases.map((release) => (
<Card
key={release.version}
as="article"
shadow="md"
className="overflow-hidden"
>
<div className="bg-muted border-border overflow-hidden border-b">
<Image
src={`/recipe-captures/${release.recipe}--teal-light-default@1x.png`}
alt={`${release.category} recipe preview`}
width={1200}
height={675}
sizes="(max-width: 640px) 75vw, 350px"
className="aspect-video w-full object-cover object-top"
/>
</div>
<CardHeader>
<div className="text-primary mb-2 flex items-center justify-between gap-3 text-xs font-medium">
<span>{release.category}</span>
<span className="bg-primary/10 rounded-full px-2 py-1 tabular-nums">
{release.version}
</span>
</div>
<CardTitle className="text-xl leading-tight tracking-tight">
{release.title}
</CardTitle>
</CardHeader>
<CardContent className="text-muted-foreground text-sm leading-relaxed">
{release.summary}
</CardContent>
<CardFooter>
<a
href={`/recipes/${release.recipe}`}
className="text-foreground focus-visible:ring-ring inline-flex items-center gap-2 rounded-sm text-sm font-medium underline-offset-4 hover:underline focus-visible:ring-2 focus-visible:outline-none"
>
{release.action}
<ArrowUpRight aria-hidden="true" className="size-4 shrink-0" />
</a>
</CardFooter>
</Card>
))}
</CardStack>
<p className="text-muted-foreground text-center text-xs">
Sample release stories · live recipe previews
</p>
</div>
);
}Design shortlist
Browse a few visual directions in a restrained fan. Select a rear card or use the controls, then open the active recipe to explore it.
Show sourceexamples/card-stack/open.tsx
"use client";
import Image from "next/image";
import {
Card,
CardContent,
CardHeader,
CardStack,
CardTitle,
} from "@dethink/components";
const designs = [
{
name: "Command center",
recipe: "command-center-dashboard",
description: "A clear view of the signals that matter.",
},
{
name: "Release room",
recipe: "release-readiness",
description: "A shared space for your next big launch.",
},
{
name: "AI workspace",
recipe: "ai-workspace",
description: "A thoughtful home for research and ideas.",
},
];
export function CardStackOpen() {
return (
<CardStack
aria-label="Design shortlist"
getCardLabel={(index) => designs[index]?.name ?? ""}
mode="open"
angle={6}
defaultActiveIndex={1}
showControls
>
{designs.map((design) => (
<Card
key={design.name}
as="article"
shadow="md"
className="overflow-hidden"
>
<Image
src={`/recipe-captures/${design.recipe}--teal-light-default@1x.png`}
alt={`${design.name} preview`}
width={1200}
height={675}
sizes="(max-width: 640px) 70vw, 400px"
className="border-border aspect-video w-full border-b object-cover object-top"
/>
<CardHeader>
<CardTitle>{design.name}</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-sm">
<p className="text-muted-foreground">{design.description}</p>
<a
href={`/recipes/${design.recipe}`}
className="text-primary focus-visible:ring-ring inline-flex rounded-sm font-medium underline underline-offset-4 focus-visible:ring-2 focus-visible:outline-none"
>
Open {design.name.toLowerCase()}
</a>
</CardContent>
</Card>
))}
</CardStack>
);
}Boundaries and custom labels
With loop disabled the controls disable at either end, and previousLabel/nextLabel rename them for assistive technology. stackOffset deepens the deck.
Show sourceexamples/card-stack/boundaries.tsx
"use client";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardStack,
CardTitle,
} from "@dethink/components";
const steps = [
{ title: "1. Install", body: "npx shadcn@latest add @dethink/card-stack" },
{ title: "2. Compose", body: "Pass Card children straight into CardStack." },
{ title: "3. Ship", body: "Theme everything through the CSS variables." },
];
export function CardStackBoundaries() {
return (
<CardStack
aria-label="Getting started steps"
loop={false}
stackOffset={14}
previousLabel="Previous step"
nextLabel="Next step"
>
{steps.map((step) => (
<Card key={step.title}>
<CardHeader>
<CardTitle>{step.title}</CardTitle>
<CardDescription>Setup walkthrough</CardDescription>
</CardHeader>
<CardContent className="text-muted-foreground font-mono text-sm">
{step.body}
</CardContent>
</Card>
))}
</CardStack>
);
}Customer proof
Portraits, a memorable quote, and a little context. External pagination uses activeIndex and onActiveIndexChange, with meaningful names and an announced selected state.
“We rethemed the whole library in an afternoon.”
One shared visual language, from the first screen to the last.
Mira
Platform lead
Illustrative customer stories · fictional people and quotes
Show sourceexamples/card-stack/controlled.tsx
"use client";
import { useState } from "react";
import Image from "next/image";
import { Quote } from "lucide-react";
import {
Button,
Card,
CardContent,
CardFooter,
CardStack,
} from "@dethink/components";
const quotes = [
{
author: "Mira",
role: "Platform lead",
image: "mira-ops",
quote: "We rethemed the whole library in an afternoon.",
detail: "One shared visual language, from the first screen to the last.",
},
{
author: "Eli",
role: "Frontend engineer",
image: "eli-engineering",
quote: "Open code means no more fighting a black box.",
detail:
"The freedom to adapt the details that make a product feel like ours.",
},
{
author: "Noah",
role: "Design systems",
image: "noah-design",
quote: "The tokens map one-to-one onto our brand.",
detail: "A consistent foundation that still leaves room for character.",
},
];
export function CardStackControlled() {
const [activeIndex, setActiveIndex] = useState(0);
return (
<div className="space-y-5">
<CardStack
aria-label="Customer quotes"
getCardLabel={(index) => quotes[index]?.author ?? ""}
mode="open"
angle={6}
activeIndex={activeIndex}
onActiveIndexChange={setActiveIndex}
>
{quotes.map((entry) => (
<Card key={entry.author} as="article" shadow="md">
<CardContent className="pt-6">
<Quote aria-hidden="true" className="text-primary mb-5 size-7" />
<blockquote className="text-xl leading-snug font-medium tracking-tight sm:text-2xl">
“{entry.quote}”
</blockquote>
<p className="text-muted-foreground mt-4 text-sm leading-relaxed">
{entry.detail}
</p>
</CardContent>
<CardFooter className="border-border flex-row items-center border-t pt-5">
<Image
src={`/avatars/showcase/${entry.image}.png`}
width={40}
height={40}
alt=""
className="size-10 rounded-full object-cover"
/>
<div className="ml-3 text-sm">
<p className="font-medium">{entry.author}</p>
<p className="text-muted-foreground text-xs">{entry.role}</p>
</div>
</CardFooter>
</Card>
))}
</CardStack>
<div
className="flex flex-wrap items-center justify-center gap-2"
aria-label="Choose a customer quote"
role="group"
>
{quotes.map((entry, index) => (
<Button
key={entry.author}
size="sm"
variant={index === activeIndex ? "solid" : "outline"}
aria-label={`Show quote from ${entry.author}`}
aria-pressed={index === activeIndex}
onClick={() => setActiveIndex(index)}
>
{index + 1}
</Button>
))}
</div>
<p className="text-muted-foreground text-center text-xs">
Illustrative customer stories · fictional people and quotes
</p>
</div>
);
}Guided walkthrough
Use showControls={false} when your own actions drive the deck. This sample has progress, a clear finish, and a way to start again.
Show sourceexamples/card-stack/hidden-controls.tsx
"use client";
import { useState } from "react";
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardStack,
CardTitle,
} from "@dethink/components";
const updates = [
{
title: "Draft",
detail: "Start here",
body: "Sketch the release notes and gather the changelog entries.",
},
{
title: "Review",
detail: "In progress",
body: "Loop in design and engineering leads for a final pass.",
},
{
title: "Publish",
detail: "Last step",
body: "Ship the announcement and notify the changelog subscribers.",
},
];
export function CardStackHiddenControls() {
const [activeIndex, setActiveIndex] = useState(0);
const [complete, setComplete] = useState(false);
const isLast = activeIndex === updates.length - 1;
return (
<div className="space-y-4">
<CardStack
aria-label="Release checklist"
activeIndex={activeIndex}
loop={false}
onActiveIndexChange={setActiveIndex}
showControls={false}
>
{updates.map((update) => (
<Card key={update.title}>
<CardHeader>
<CardTitle>{update.title}</CardTitle>
<CardDescription>{update.detail}</CardDescription>
</CardHeader>
<CardContent className="text-muted-foreground text-sm">
{update.body}
</CardContent>
</Card>
))}
</CardStack>
<div className="flex justify-center gap-2">
<Button
size="sm"
variant="outline"
disabled={activeIndex === 0 || complete}
onClick={() => setActiveIndex((index) => index - 1)}
>
Back
</Button>
<Button
size="sm"
disabled={complete}
onClick={() =>
isLast ? setComplete(true) : setActiveIndex((index) => index + 1)
}
>
{complete ? "Completed" : isLast ? "Finish walkthrough" : "Continue"}
</Button>
</div>
<div className="text-muted-foreground text-center text-sm" role="status">
{complete
? "Walkthrough complete. Nothing has been published."
: `Step ${activeIndex + 1} of ${updates.length}`}
</div>
{complete ? (
<div className="text-center">
<Button
size="sm"
variant="ghost"
onClick={() => {
setComplete(false);
setActiveIndex(0);
}}
>
Start again
</Button>
</div>
) : null}
</div>
);
}Use a stack for a small collection people browse one card at a time. Use a grid or table when they need to compare details side by side.
Keep cards concise and similarly sized. Every card remains mounted, so this is not a virtualized list. Direct Card children are required; wrapper components and fragments are not supported.
Give the deck an accessible name. Arrow, Home, and End keys navigate while the deck itself has focus. Enter or Space selects a rear fan card and returns focus to the deck, ready for further navigation. Nested links and form controls retain their own keyboard behavior.
Only the active card is interactive; other card contents are inert. Position changes are announced politely. Motion follows reduced-motion preferences, and the resting deck stays still. Include explicit controls when the fan edges are difficult to select.
For directional handoffs, optional swipe, and actionable briefing or review recipes, see the animated Card Stack.
The preview images and portraits in these examples are local showcase assets. Replace their paths and destination links with your own content when copying a recipe.
CardStack renders a div and accepts all native div attributes. Inactive cards are inert and hidden from assistive technology; only the active card is interactive.
| Prop | What it does | Default |
|---|---|---|
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" |


