Skip to content
Dethink Components

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 files

Implemented 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.

Usage
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.

CHAPTER 01

Start with the question

Card 1 of 7: Start with the question
Show sourceexamples/card-stack/bounded.tsx
examples/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.

From the release room01 / 03
Release operations recipe preview
Release operations02.8

A calmer way to ship.

One shared checklist. Clear ownership. Every detail ready for the next release.
Card 1 of 3: A calmer way to ship.

Sample release stories · live recipe previews

Show sourceexamples/card-stack/basic.tsx
examples/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.

Card 2 of 3: Release room
Show sourceexamples/card-stack/open.tsx
examples/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.

1. Install

Setup walkthrough

npx shadcn@latest add @dethink/card-stack
Card 1 of 3
Show sourceexamples/card-stack/boundaries.tsx
examples/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

Card 1 of 3: Mira

Illustrative customer stories · fictional people and quotes

Show sourceexamples/card-stack/controlled.tsx
examples/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.

Draft

Start here

Sketch the release notes and gather the changelog entries.
Card 1 of 3
Step 1 of 3
Show sourceexamples/card-stack/hidden-controls.tsx
examples/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.

CardStack props
PropWhat it doesDefault
getCardLabel(index: number) => stringExplicit card name for fan selectors and position announcements. Keep it short and meaningful; empty labels fall back to the card number.Not set
visibleCountnumberMaximum 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"
activeIndexnumberControlled index of the active card. Pair with onActiveIndexChange.Not set
defaultActiveIndexnumberInitial active card for uncontrolled usage.0
onActiveIndexChange(index: number) => voidFires with the normalized index whenever navigation changes the active card.Not set
loopbooleanWrap from the last card back to the first. When false, controls disable at the ends.true
showControlsbooleanShow or hide the previous/next icon buttons below the deck in either mode.stack mode with 2+ cards
showPreviousControlbooleanShow or hide only the previous (back) icon button. Overrides showControls for that side.showControls
showNextControlbooleanShow or hide only the next icon button. Overrides showControls for that side.showControls
previousLabelstringAccessible label for the built-in previous control."Show previous card"
nextLabelstringAccessible label for the built-in next control."Show next card"
anglenumberPer-card rotation in open mode, clamped between 0 and 30 degrees.15
stackOffsetnumberPixel offset applied per depth level in stack mode, clamped between 0 and 32.8
aria-label / aria-labelledbystringAccessible name of the focusable group. Always pass one that describes the content."Card stack"