Skip to content
Dethink Components

ComponentsCardStack

CardStack

Let users browse cards arranged in a stack or fan.

On this page

Use this component from the local workspace package. Follow the setup guide first. A public npm package and registry are not available yet.

View registry files

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.

Stacked deck

The default stack mode layers cards behind the active one and shows previous/next controls automatically when there is more than one card.

v1.4.0

June 2026

Date suite: Calendar, DatePicker, DateRangePicker, DateTimePicker.
Show sourceexamples/card-stack/basic.tsx
examples/card-stack/basic.tsx
"use client";

import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardStack,
  CardTitle,
} from "@dethink/components";

const releases = [
  {
    version: "v1.4.0",
    date: "June 2026",
    summary:
      "Date suite: Calendar, DatePicker, DateRangePicker, DateTimePicker.",
  },
  {
    version: "v1.3.0",
    date: "May 2026",
    summary: "DataTable with sorting, filtering, and row selection.",
  },
  {
    version: "v1.2.0",
    date: "April 2026",
    summary: "Overlay primitives: Popover, Tooltip, and DropdownMenu.",
  },
];

export function CardStackBasic() {
  return (
    <CardStack aria-label="Latest releases">
      {releases.map((release) => (
        <Card key={release.version}>
          <CardHeader>
            <CardTitle>{release.version}</CardTitle>
            <CardDescription>{release.date}</CardDescription>
          </CardHeader>
          <CardContent className="text-muted-foreground text-sm">
            {release.summary}
          </CardContent>
        </Card>
      ))}
    </CardStack>
  );
}

Open fan

Open mode spreads the deck into an arc — angle controls the per-card rotation, and clicking any visible card brings it to the front.

Team

$29 per month

Unlimited projects, shared themes.
Show sourceexamples/card-stack/open.tsx
examples/card-stack/open.tsx
"use client";

import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardStack,
  CardTitle,
} from "@dethink/components";

const plans = [
  { name: "Starter", price: "$0", blurb: "Two projects, community support." },
  { name: "Team", price: "$29", blurb: "Unlimited projects, shared themes." },
  { name: "Scale", price: "$99", blurb: "SSO, audit log, priority support." },
];

export function CardStackOpen() {
  return (
    <CardStack
      aria-label="Pricing plans"
      mode="open"
      angle={10}
      defaultActiveIndex={1}
    >
      {plans.map((plan) => (
        <Card key={plan.name}>
          <CardHeader>
            <CardTitle>{plan.name}</CardTitle>
            <CardDescription>{plan.price} per month</CardDescription>
          </CardHeader>
          <CardContent className="text-muted-foreground text-sm">
            {plan.blurb}
          </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
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>
  );
}

Controlled

Drive the stack from external state with activeIndex and onActiveIndexChange — here a row of index buttons doubles as a pagination indicator.

We rethemed the whole library in an afternoon.

Dana, platform lead

Quote 1 of 3
Show sourceexamples/card-stack/controlled.tsx
examples/card-stack/controlled.tsx
"use client";

import { useState } from "react";
import {
  Button,
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardStack,
  CardTitle,
} from "@dethink/components";

const quotes = [
  {
    author: "Dana, platform lead",
    quote: "We rethemed the whole library in an afternoon.",
  },
  {
    author: "Miguel, frontend dev",
    quote: "Open code means no more fighting a black box.",
  },
  {
    author: "Priya, design systems",
    quote: "The tokens map one-to-one onto our brand.",
  },
];

export function CardStackControlled() {
  const [activeIndex, setActiveIndex] = useState(0);

  return (
    <div className="space-y-4">
      <CardStack
        aria-label="Customer quotes"
        mode="open"
        activeIndex={activeIndex}
        onActiveIndexChange={setActiveIndex}
      >
        {quotes.map((entry) => (
          <Card key={entry.author}>
            <CardHeader>
              <CardTitle>“{entry.quote}”</CardTitle>
              <CardDescription>{entry.author}</CardDescription>
            </CardHeader>
            <CardContent className="text-muted-foreground text-sm">
              Quote {quotes.indexOf(entry) + 1} of {quotes.length}
            </CardContent>
          </Card>
        ))}
      </CardStack>
      <div className="flex justify-center gap-2">
        {quotes.map((entry, index) => (
          <Button
            key={entry.author}
            size="sm"
            variant={index === activeIndex ? "solid" : "outline"}
            onClick={() => setActiveIndex(index)}
          >
            {index + 1}
          </Button>
        ))}
      </div>
    </div>
  );
}

Hidden controls

Hide the built-in icon buttons when another element drives navigation. showControls={false} hides both, while showPreviousControl/showNextControl hide just one side — here the back chevron is hidden and custom buttons advance the deck.

Draft

Start here

Sketch the release notes and gather the changelog entries.
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 isLast = activeIndex === updates.length - 1;

  return (
    <div className="space-y-4">
      <CardStack
        aria-label="Release checklist"
        activeIndex={activeIndex}
        loop={false}
        onActiveIndexChange={setActiveIndex}
        showPreviousControl={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}
          onClick={() => setActiveIndex((index) => index - 1)}
        >
          Back
        </Button>
        <Button
          size="sm"
          disabled={isLast}
          onClick={() => setActiveIndex((index) => index + 1)}
        >
          {isLast ? "Done" : "Continue"}
        </Button>
      </div>
    </div>
  );
}

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
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
showControlsbooleanForce the built-in previous/next icon buttons on or off 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"