Skip to content
Dethink Components

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

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

Research briefingFictional sample · 3 findings

Directional signal

Make the first step obvious

New members may need a clearer route from an empty workspace to their first useful result.

Inspect sample evidence

Small, fictional sample. Validate with real onboarding sessions.

Card 1 of 3: Make the first step obvious
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
examples/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.

0 of 3 reviewed

Next experiment · Activation

Prototype a first-project checklist

New members may need a clearer route from an empty workspace to their first useful result.

Card 1 of 3: Make the first step obvious

Fictional ideas. Decisions are local and reversible. Swiping never saves or defers an item.

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

CardStackAnimated props
PropWhat it doesDefault
swipebooleanOpt-in horizontal touch/pen navigation. Does not approve or dismiss cards.false
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"