Swipe Deck React component for Tailwind CSS v4
v1.0.0Added in 1.0.0
A React swipe card deck: decide on one card at a time, swipe right to accept, left to reject, optionally up for later, with undo.
Drag the top card with a finger or the mouse: it follows and tilts, a stamp (Accept, Reject, Later) fades in on the side you are heading for, the matching round button tints and grows, and the cards behind rise into place. Past a quarter of the card (or with a quick flick) the stamp settles at full strength and the phone ticks; let go there and the card flies off at the speed of your throw and the next one is on top, let go earlier and it springs back. With only left and right allowed, vertical swipes on the card still scroll the page and the card only gives a little up and down. The round buttons under the deck throw the card the same way; the small undo button (a circular arrow) brings the last card back, flying in from the side it left. A save that fails (a rejected onSwipe promise) brings the card back with an error pill. Keyboard: Tab to the card, Left / Right (and Up when allowed) decide with the same animation, Backspace or Cmd/Ctrl+Z undo, and focus follows to the next card and to the empty state at the end. Buttons and links inside a card stay clickable and never start a drag. Every decision is announced to screen readers. With reduced motion the card does not tilt and leaves with a short fade and slide.
Swipe Deck is a Pro React component in the React mobile components category, written in TypeScript for React 19 and Tailwind CSS v4. It installs as source with npx wingo-ui@latest add swipe-deck, together with Button, Empty State, useControllableState, Haptics, Motion presets and 2 more and the npm packages lucide-react and motion.
Add to a project
Requirements: React 19 and Tailwind CSS v4, in Next.js (App Router or Pages), Vite or React Router. Installation guide · Next.js setup · Vite setup
npx wingo-ui@latest add swipe-deckSwipe Deck is part of Pro, with 298 more Pro items: $8 a month or $150 once. 14-day refund before you download any Pro item. See plans
Usage
Import SwipeDeck from @/components/ui/swipe-deck.
import { useState } from "react"
import { SwipeDeck, type SwipeDeckDirection } from "@/components/ui/swipe-deck"
type Receipt = { id: string; vendor: string; amount: string; category: string }
const receipts: Receipt[] = [
{ id: "exp-2041", vendor: "Whole Foods Market", amount: "$214.50", category: "Hospitality" },
{ id: "exp-2042", vendor: "FedEx", amount: "$86.00", category: "Shipping" },
{ id: "exp-2044", vendor: "Hilton Boston", amount: "$412.00", category: "Lodging" },
]
export function ApproveExpenses({ save }: { save: (id: string, approved: boolean) => Promise<void> }) {
const [approved, setApproved] = useState(0)
return (
<SwipeDeck
items={receipts}
getItemLabel={(r) => `${r.vendor}, ${r.amount}`}
renderCard={(r) => (
<div className="flex h-full flex-col gap-2 p-5">
<span className="text-[15px] font-semibold">{r.vendor}</span>
<span className="text-4xl font-bold tabular-nums">{r.amount}</span>
<span className="text-sm text-muted-foreground">{r.category}</span>
</div>
)}
onSwipe={(r, direction: SwipeDeckDirection) => {
if (direction === "right") setApproved((n) => n + 1)
return save(r.id, direction === "right")
}}
labels={{ left: "Reject", right: "Approve", undo: "Undo", empty: `Done: ${approved} approved` }}
/>
)
}Props
items*T[]The cards, each with a stable id. Appending while in use fills the stack; removing clamps the index.
renderCard*(item: T, state: { index: number; isTop: boolean }) => ReactNodeThe card's content. The deck draws the card surface (radius, shadow, overflow) around it.
getItemLabeldefault"item.id"(item: T) => stringThe card's accessible name and the text announced after a decision.
directionsdefault"left, right"("left" | "right" | "up")[]The allowed directions. Adding "up" gives a third choice (Later), a stamp at the bottom of the card and a smaller middle button; without "right" its button is hidden.
visibleCardsdefault3numberCards drawn in the stack, 1 to 4. The rest mount when they reach it; fewer items draw fewer cards.
stackdefault"stack""stack" | "fan" | "none"stack: the cards behind peek out below, scaled to 0.95 / 0.9. fan: they lean ±3° alternately. none: only the top card shows; the next fades in as it leaves.
rotationdefault12numberDegrees of tilt at a full-width drag, around the bottom of the card. 0 keeps it upright.
leftTonedefault"danger""neutral" | "brand" | "success" | "warning" | "danger" | "info"The reject stamp and button. Stamps always carry text and an icon, so color is never the only cue.
rightTonedefault"success""neutral" | "brand" | "success" | "warning" | "danger" | "info"The accept stamp and button. Brand orange suits a like on a marketplace.
upTonedefault"info""neutral" | "brand" | "success" | "warning" | "danger" | "info"The later stamp and button, when "up" is allowed.
radiusdefault"lg""md" | "lg"Card radius: md 16px (rounded-2xl), lg 24px (rounded-3xl).
showActionsdefaulttruebooleanThe round 56px buttons under the deck: the mouse, keyboard and screen reader path to every decision.
showUndodefaulttruebooleanThe undo button next to them. Off also turns off Backspace and Cmd/Ctrl+Z.
stampsdefaulttruebooleanThe labels that fade in on the card while it is dragged and lock in when a direction is armed.
hapticsdefaulttruebooleanA medium tick when a drag arms a direction, light on commit, selection on undo, error when onSwipe rejects. Through lib/haptics, silent on desktops.
disableddefaultfalsebooleanDims the deck and blocks drags, keys and buttons.
indexnumberHow many cards are decided, to control the deck from outside (set 0 to start over).
defaultIndexdefault0numberThe starting index while uncontrolled: how many cards count as decided on mount. At the item count the deck opens on its empty state.
onIndexChange(index: number) => voidFires after every decision, undo and failed save.
onSwipe(item: T, direction: SwipeDeckDirection) => (void | Promise<unknown>)Runs once the card has left. Return a promise to save: the deck waits for it before the next decision, and a rejection brings the card back with labels.error.
onUndo(item: T, direction: SwipeDeckDirection) => voidRuns when a card comes back through the undo button or a key.
onEmpty() => voidRuns after the last card is decided (after its save resolves, when onSwipe returns a promise).
emptyStateReactNodeShown in the deck's place after the last card. Defaults to <EmptyState size="sm" title={labels.empty} /> with a check; its first button gets focus.
iconsPartial<Record<"left" | "right" | "up", ReactNode>>Icons for the stamps and buttons per direction. Defaults: X, Check, Clock.
setApi(api: SwipeDeckApi) => voidHands out { swipe(direction), undo(), index } to drive the deck from your own controls. swipe animates like a button press.
labelsPartial<SwipeDeckLabels>Every string: left "Reject", right "Accept", up "Later", undo "Undo", empty "All done", region "Cards", instructions (read with the top card; the English default names Up when it is allowed and drops Backspace without undo), decided(label, direction, remaining) for the announcement, error "Couldn't save. Try again.". English defaults in DEFAULT_SWIPE_DECK_LABELS.
classNamesPartial<Record<"stack" | "card" | "stamp" | "actions" | "action" | "undo" | "empty" | "error", string>>Extra classes for the inner parts. The stack is aspect-[3/4]; override it here, e.g. { stack: "aspect-[4/5]" }.
classNamestringExtra classes for the root, merged last. The deck is capped at max-w-sm and centered. A thrown card flies past the deck's edges, so give a page that can scroll sideways an overflow-x-clip wrapper.
Parts
DEFAULT_SWIPE_DECK_LABELS
constantThe English strings: the button names, Undo, the empty state, the region, the instructions, the decision announcement and the save error.
Examples
Three directions
Up postpones with Later; the stamp for up sits at the bottom of the card.
Fan of photo cards
Northwind Market rentals in a fanned stack, brand tone and a heart for the like.
Lead triage
No stack and no tilt for a calm, business look; the card's own button is still clickable.
Async save that fails
onSwipe returns a promise; a rejection brings the card back with the error.
Empty state with reset
After the last card, a custom empty state whose action resets the controlled index.
Keyboard only
No buttons: focus the card, then arrows decide and Backspace undoes.
Files
- components/ui/swipe-deck.tsx
FAQ
Is the React Swipe Deck component free?
The preview, props and docs are free; copying the source needs a Pro plan. See plans.
How do I install Swipe Deck?
Run npx wingo-ui@latest add swipe-deck. It also adds the Wingo UI items it builds on (Button, Empty State, useControllableState, Haptics, Motion presets and 2 more) and the npm packages lucide-react and motion. With shadcn, add the @wingo-ui registry with your API key, then run npx shadcn@latest add @wingo-ui/swipe-deck.
See also
More React mobile components
Articles using this component
- React Swipe Cards: A Tinder-Style Deck With Undo
Mobile UI in React9 min read