Swipe Cards
A two-verdict card deck — the top card tilts under the pointer, stamps its verdict as it nears the threshold, commits on distance or flick, and answers to arrow keys, buttons and an undo stack just the same.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/swipe-cards.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "SwipeCards" component: a deck where every
card gets one of two verdicts (lucide-react for the three action icons, plus a
cn() class merger; no other runtime dependency).
Contract
- Export a forwardRef div extending React.HTMLAttributes<HTMLDivElement>.
- items: { id, title, subtitle?, description?, imageSrc?, imageAlt?, tags? }[] —
a queue decided from index 0 upwards. id must be stable: it keys the DOM node
the exit animation runs on.
- leftLabel = "Nope", rightLabel = "Like" — the verdict stamped on the card, said
in the live region, and used in the buttons' accessible names.
- threshold = 96 — px of horizontal travel that commits on distance alone.
- velocityThreshold = 0.6 — px per ms of release speed that commits a short flick.
- maxRotation = 16 — degrees of tilt at half a card width of travel.
- showUndo = true — the undo button AND the U shortcut, together; hiding the
affordance must also retire the key.
- showCounter = true, disabled = false, label = "Swipe deck".
- emptyState?: ReactNode — replaces the built-in finished panel.
- renderCard?: (item) => ReactNode — takes over the inside of every card; the
frame, the stamps, the gestures and the keyboard map stay.
- onDecide / onUndo: (decision) => void where decision =
{ item, index, direction: "left" | "right", via: "gesture" | "keyboard" | "button" }.
- onRefuse: (message: string) => void — fires with the exact refused sentence.
- The component owns the cursor and the undo stack; there is no controlled index.
Remount with a new key to reset the deck.
Behavior
- Mount three cards at most: the top one plus two behind it. Deeper cards are
fully occluded, so rendering them only costs DOM nodes and image requests.
- Drag: on the STAGE's pointerdown (not the card's) call setPointerCapture, focus
the stage, and measure the card width once. Keep dx/dy in a ref for the release
test and in state for the transform. Velocity comes from event.timeStamp deltas
between moves — event data, never a clock read at render time.
- Every card writes the SAME transform function list in the same order —
translate(x, y) rotate(deg) scale(n) — so a card promoted from the pile to the
top interpolates componentwise instead of falling back to matrix decomposition.
- Follow transform on the top card: translate(dx px, dy px)
rotate(clamp(dx / (width / 2), -1, 1) * maxRotation deg) scale(1).
- Card at depth d rests at translate(0, d * 14px) scale(1 - d * 0.06).
- Commit progress p = clamp(abs(dx) / max(1, threshold), 0, 1). p drives two
things: the opacity of the verdict stamp on the side being swiped towards, and
the lift of the card directly underneath, whose depth is read as (1 - p) — it
rises into the top slot exactly as far as the release has travelled.
- Release commits when abs(dx) >= threshold, OR when abs(velocity) >=
velocityThreshold and abs(dx) >= 24px (a flick that is not a jittery tap).
Direction is the sign of dx when the distance was met, otherwise the sign of
the velocity. Below both, dx returns to 0 and the same transition springs the
card home.
- Commit is synchronous: the cursor advances, the decision is pushed on the undo
stack and onDecide fires immediately. The card that left is pure decoration — it
keeps the DOM position it already had (window starts one index earlier), so
React reuses the element and the browser transitions from exactly the transform
the finger left it at, to translate(±130%, dy px) rotate(±maxRotation * 1.5 deg)
opacity 0 over 300ms. Never gate state on transitionend.
- That ghost is cleared by one backstop timeout (flight + 160ms), re-armed per
decision and cleared on unmount, so a hidden tab or an interrupted transition
can never leave a dead card covering the deck.
- Undo pops the stack, moves the cursor back and fires onUndo. It first cancels
any ghost in flight — otherwise the returning card would render twice, once as
itself and once as its own departure.
- Keyboard, on the stage: ArrowLeft = leftLabel, ArrowRight = rightLabel, U =
undo, each preventDefault'ed. Enter and Space are deliberately unbound — with
two opposite verdicts there is no sensible default action; they belong to the
three real buttons. Bail out on event.defaultPrevented, on
nativeEvent.isComposing (mid-IME the key belongs to the IME), on any Ctrl /
Meta / Alt combination, and on event.target !== event.currentTarget — the
stage is the only focusable node the deck owns, so a bubbled key means a
control inside a custom card has focus and the arrows are its caret's.
- Refusals are never silence: a swipe or a press with no card left, an undo with
an empty stack, or anything at all while disabled produces one sentence in the
visible status line, in the live region and through onRefuse. The sentence
clears itself after 4s, which is what lets the same refusal be announced again
instead of being swallowed as a no-change.
- disabled uses aria-disabled plus handler guards, never the native attribute:
the last decision empties the deck under the very button being pressed, and the
browser blurs a node the instant it becomes disabled.
- Focus never moves on a commit, because the focusable element is the stage, which
outlives every card. Nothing is ever handed back to <body>.
- prefers-reduced-motion (subscribed with useSyncExternalStore so it reacts to OS
changes): the ghost is never mounted, the tilt is 0, the lift is off and there
is no spring-back easing. The follow transform stays — it is 1:1 with the
finger, direct manipulation rather than animation — and every verdict, stamp,
undo and announcement works identically.
- Edge cases: items = [] renders the finished panel and refuses politely; items
shrinking under the cursor is clamped every render; a duplicate id cannot
collide because the React key is index + id; threshold 0 is floored at 1 so the
progress ratio is never NaN; secondary mouse buttons and non-primary pointers
are ignored; a pointerdown landing on an a / button / input / textarea / select
inside a custom card leaves that control its own gesture, and the keyboard
guard above leaves it its own keys.
- Cleanup: two timeouts (ghost backstop, status sentence), both cleared on the
next decision and on unmount. Pointer capture is released by the browser at
pointerup, pointercancel and lostpointercapture all reset the gesture ref, and
there are no window listeners or observers to leak.
Rendering & styling
- Stage: relative aspect-[3/4] rounded-xl touch-pan-y select-none, tabIndex 0,
focus-visible:ring-2 ring-ring ring-offset-2, cursor-grab / cursor-grabbing.
touch-pan-y keeps vertical page scrolling on touch while horizontal drags
belong to the deck.
- Card: absolute inset-0 overflow-hidden rounded-xl border bg-card
text-card-foreground shadow-lg; the default body is an object-cover image with
a title / subtitle / clamped description / tag pills underneath. Images are
draggable={false} + pointer-events-none so the browser's native image drag
cannot hijack the gesture.
- Stamps: absolute, border-4 uppercase tracking-widest, the accept one top-left
at -12deg in border-primary/text-primary, the reject one top-right at 12deg in
border-destructive/text-destructive, both aria-hidden with p as their opacity.
- ARIA: root role="group" aria-roledescription="Swipe card deck" aria-label; the
stage role="group" aria-roledescription="Swipe card" with aria-label "Card 3 of
8: <title>" and aria-describedby pointing at the visible hint line; cards behind
and the ghost aria-hidden; the counter pill aria-hidden; buttons carry
aria-keyshortcuts (ArrowLeft / U / ArrowRight) and an aria-label naming the card
they act on; one sr-only role="status" aria-live="polite" aria-atomic region
carries every verdict, undo and refusal.
- Semantic tokens only: bg-card, text-card-foreground, bg-background, bg-muted,
border, text-muted-foreground, text-primary, text-destructive, ring-ring,
accent. No hex, no rgb().
Customization levers
- Feel: threshold and velocityThreshold set how eager a commit is (48px + 0.3
for a hair trigger, 140px for a deliberate one); maxRotation is the whole tilt
personality — 0 gives a flat, translation-only deck.
- Depth: the 14px offset and 0.06 scale step per layer control how thick the pile
looks; raising the mounted count past 3 buys nothing visually and costs an
image request per layer.
- Exit: the 130% travel, the 1.5x rotation multiplier and the 300ms duration are
the three knobs; keep the travel past 100% so the card fully clears the frame,
and keep the backstop timeout above the duration.
- Verdicts: leftLabel / rightLabel rename the stamps, the buttons and every
announcement at once — "Later" / "Ship", "Skip" / "Save", "Again" / "Got it".
Swap the two icons to match.
- Chrome: showUndo and showCounter drop the undo affordance (and its shortcut)
and the "3 / 8" pill; emptyState replaces the finished panel.
- Card body: renderCard replaces the inside entirely — a media-free triage card, a
flashcard, a pricing offer. The stamps sit above whatever you render.
- Wiring: onDecide / onUndo are where a queue is drained, an optimistic mutation
is fired, or analytics are logged; the component never fetches or persists
anything.Concepts
- Two-verdict deck — every card leaves with a meaning attached, so left and right are opposite outcomes rather than two ways of saying “next”; that is the whole difference from a photo stack or a carousel.
- Threshold-or-velocity commit — distance answers the deliberate drag, release velocity answers the quick flick, and a 24px floor keeps a fast jitter from counting as either.
- Verdict stamp preview — the stamp's opacity is the commit progress, so the card tells you what it is about to become while you can still change your mind by dragging back.
- Synchronous commit, decorative exit — the cursor moves the instant the gesture ends and the flying card is only a leftover element finishing its transition, which is why nothing breaks when the animation is switched off or never finishes.
- Undo stack — decisions are pushed with their index and direction, so undo restores the exact card and tells the parent which decision to unwind; it also cancels any card still in flight, or the same item would render twice.
- Gesture is never the only path — buttons, ← / → and U reach the same three actions, focus lives on the stage rather than on a card that unmounts, and reduced motion removes the tilt and the flight without touching the outcome.
Stories Viewer
A story-format viewer — one segmented progress bar per item, tap zones for previous/next, press-and-hold to pause, and auto-advance across image and video stories.
Scratch Card
A canvas foil erased under the pointer, with a coverage threshold that opens the card itself and a Reveal button that does the same from the keyboard.