Cards & Grids

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

New project or installation trouble? Start here →

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

3
96
500
Active edited usageDefault props
<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.json

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

PropTypeDefaultDescription
childrenReact.ReactNode—Each direct child is one card; the first one starts on top
indexnumber—Controlled index of the top card
defaultIndexnumber0Initial 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
loopbooleantruetrue cycles a drawn card back to the bottom of the deck; false shows the empty state once the deck runs out
visibleCountnumber3Number of layers rendered in the stack at once (including the top card)
drawThresholdnumber96Drag displacement threshold that triggers a draw (px)
velocityThresholdnumber500Flick velocity threshold that triggers a draw (px/s)
emptyStateReact.ReactNode—Content shown once the deck runs out with loop={false}
cardClassNamestring—Styles applied to each card's surface
classNamestring—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 loop on, the drawn card cycles back to the bottom.
  • Supports controlled and uncontrolled modes (pass index + onIndexChange, or defaultIndex); 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" and aria-roledescription, with an aria-live region 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/.