Card Deck
A draggable stacked card deck — flick a card out in any of four directions, the next one fills in automatically, with infinite loop or draw-until-empty modes.
Bring this into your project
Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.
npx webberui@latest add card-deck.enCard Deck is a React draggable card stack component built with Tailwind CSS and Motion (motion/react). The top card can be flicked out in four directions, the next one springs forward to take its place, and the cards behind sit at stable stack angles.
Playground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<CardDeck/>
The complete example includes the preview layout and data. Install the component first, then paste this file into your project.
When to use
- Flashcards and quiz practice: one card in focus at a time, with a completion screen once a round is done.
- Onboarding or feature tours: three to five key points as a deck the user flips through at their own pace.
- An interactive showcase on a landing or campaign page, drawing out portfolio pieces, customer stories or product features in turn.
- Card browsing on mobile, where dragging replaces previous/next arrow buttons.
- Not a good fit: for a like/skip style left-or-right decision flow, use Swipe Cards.
Installation
npx webberui@latest add https://webberui.com/r/card-deck.en.jsonOr, once registries are configured in components.json, install it as @webberui/card-deck.
Usage
Each direct child is one card, and the first one starts on top. The container needs an explicit size (the cards stack on top of each other with absolute positioning).
import { CardDeck } from "@/components/ui/card-deck";
<div className="h-72 w-64">
<CardDeck className="h-full w-full">
<div className="flex h-full items-center justify-center">Card one</div>
<div className="flex h-full items-center justify-center">Card two</div>
<div className="flex h-full items-center justify-center">Card three</div>
</CardDeck>
</div>Drawing programmatically
Grab draw() / reset() through a ref so a button can trigger a draw alongside the arrow keys and dragging.
import { CardDeck, type CardDeckHandle } from "@/components/ui/card-deck";
const deckRef = React.useRef<CardDeckHandle>(null);
<CardDeck ref={deckRef}>{/* ... */}</CardDeck>
<button onClick={() => deckRef.current?.draw("left")}>Draw</button>Draw until empty
With loop={false}, cards are drawn away one by one and emptyState shows once the deck runs out.
<CardDeck loop={false} emptyState={<p>Nothing left</p>}>
{/* ... */}
</CardDeck>Examples
Controlled mode with progress
Pass index and onIndexChange to manage the top card from outside, so you can show progress next to the deck.
"use client";
import * as React from "react";
import { CardDeck } from "@/components/ui/card-deck";
const tips = ["Create a project", "Invite teammates", "Connect GitHub", "Set up notifications"];
export function OnboardingDeck() {
const [index, setIndex] = React.useState(0);
return (
<div className="flex flex-col items-center gap-4">
<div className="h-72 w-64">
<CardDeck index={index} onIndexChange={setIndex} className="h-full w-full">
{tips.map((tip) => (
<div key={tip} className="flex h-full items-center justify-center p-6 text-lg font-medium">
{tip}
</div>
))}
</CardDeck>
</div>
<p className="text-sm text-neutral-500">
Card {index + 1} of {tips.length}
</p>
</div>
);
}Recording flashcard results by draw direction
onDraw receives the drawn card's index and direction, so a right flick can mean "got it" and a left flick "review again". Combine it with loop={false} and ref.reset() for one practice round.
"use client";
import * as React from "react";
import { CardDeck, type CardDeckHandle, type DrawDirection } from "@/components/ui/card-deck";
const words = ["ephemeral", "ubiquitous", "pragmatic", "meticulous"];
export function FlashcardDeck() {
const deckRef = React.useRef<CardDeckHandle>(null);
const [review, setReview] = React.useState<string[]>([]);
function handleDraw(index: number, direction: DrawDirection) {
if (direction === "left") setReview((list) => [...list, words[index]]);
}
return (
<div className="h-72 w-64">
<CardDeck
ref={deckRef}
loop={false}
onDraw={handleDraw}
className="h-full w-full"
emptyState={
<div className="flex h-full flex-col items-center justify-center gap-3 text-sm">
<p>Round complete, {review.length} to review</p>
<button
onClick={() => {
setReview([]);
deckRef.current?.reset();
}}
>
Start over
</button>
</div>
}
>
{words.map((word) => (
<div key={word} className="flex h-full items-center justify-center text-xl">
{word}
</div>
))}
</CardDeck>
</div>
);
}Tuning the look and feel
cardClassName applies to every card surface; visibleCount sets how many layers stack behind, and raising drawThreshold and velocityThreshold means a card needs a longer drag or faster flick before it is drawn.
<CardDeck
visibleCount={4}
drawThreshold={140}
velocityThreshold={800}
cardClassName="rounded-3xl border-0 bg-neutral-900 text-white"
className="h-80 w-72"
>
{/* ... */}
</CardDeck>Props
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | — | Each direct child is one card; the first one starts on top |
index | number | — | Controlled index of the top card |
defaultIndex | number | 0 | Initial top-card index in uncontrolled mode |
onIndexChange | (index: number) => void | — | Fires when the top card changes |
onDraw | (index: number, direction: DrawDirection) => void | — | Fires when a card is drawn away, receiving the drawn card's index and direction |
loop | boolean | true | true cycles a drawn card back to the bottom of the deck; false shows the empty state once the deck runs out |
visibleCount | number | 3 | Number of layers rendered in the stack at once (including the top card) |
drawThreshold | number | 96 | Drag displacement threshold that triggers a draw (px) |
velocityThreshold | number | 500 | Flick velocity threshold that triggers a draw (px/s) |
emptyState | React.ReactNode | — | Content shown once the deck runs out with loop={false} |
cardClassName | string | — | Styles applied to each card's surface |
className | string | — | Applied to the deck container |
DrawDirection is "up" | "down" | "left" | "right".
The ref receives a CardDeckHandle: draw(direction?) draws the top card away (upward by default), and reset() returns to the first card.
How it works
- The top card can be dragged freely in all four directions; once the displacement or flick velocity crosses the threshold it flies out that way, and below the threshold it springs back into place.
- While dragging, the card tilts slightly in proportion to the horizontal displacement; the cards behind take stack angles hashed stably from their index, so the deck looks like a naturally piled stack.
- After a draw, the next card springs into the front automatically and a new card joins the back of the deck; with
loopon, the drawn card cycles back to the bottom. - Supports controlled and uncontrolled modes (pass
index+onIndexChange, ordefaultIndex); when the number of children changes, the uncontrolled index is clamped back into the valid range automatically.
Accessibility
- The top card is focusable (
tabIndex), arrow keys map to the four draw directions, and Enter / Space draws it upward. - The deck container carries
role="group"andaria-roledescription, with anaria-liveregion announcing the current card number and how many remain. - When the user has "reduce motion" enabled at the system level, dragging and the fly-out offset are disabled and a draw becomes a fade out, avoiding large movements.
FAQ
Does it work with the Next.js App Router?
Yes. The component file already starts with "use client", so you can import it directly from a Server Component page. Only the wrapper that uses a ref or useState to drive the deck needs to be a Client Component.
Why are the cards invisible or zero height?
The cards stack with absolute positioning (absolute inset-0), so the deck does not create its own height. Give CardDeck or its parent an explicit width and height, for example className="h-72 w-64".
Dragging feels too sensitive or too stiff. How do I tune it?
Adjust drawThreshold (drag distance, default 96 px) and velocityThreshold (flick speed, default 500 px/s). Crossing either one draws the card; if neither is reached, the card springs back.
What happens with "reduce motion" turned on?
The component reads the setting with Motion's useReducedMotion. When it is on, dragging and the fly-out offset are disabled, the back cards lose their tilt, and a draw becomes a fade out. Arrow keys, Enter / Space and ref.draw() still draw cards.
What are the dependencies, and can I skip the CLI?
It depends on motion, clsx and tailwind-merge. Without the CLI, switch to the Manual tab in the installation section, install the dependencies and copy the source into components/ui/.
Morph Expand Card
Click a card to morph it seamlessly into a centered detail panel via shared layout; only one card in a group is expanded at a time.
Spotlight Card
A cursor spotlight card — a border glow and a fainter background bloom track the cursor together and fade out on leave, with each card in a row staying independent.