Overview
A card deck that stays glued to the finger, projects the release with exponential decay, and rubber-bands past the edge. Left and right mean different things (Skip / Save by default): a stamp fades in on the card and the matching button swells as you drag toward it. The cards behind rise with the drag, so the next card is already in place when the top one leaves. Undo throws the last card back in from the side it left. Arrow keys swipe, Backspace undoes, and touch keeps vertical page scroll.
Installation
Use the CLI to install the component automatically:
$ pnpm dlx shadcn@latest add https://ui.sanjid.in/r/swipe-deck.jsonInstall dependencies:
$ pnpm add motionAdd the utility function for class merging:
lib/utils.ts
tsx
1import { ClassValue, clsx } from "clsx";2import { twMerge } from "tailwind-merge";3 4export function cn(...inputs: ClassValue[]) {5 return twMerge(clsx(inputs));6}
Copy the component code into your project:
tsx
1"use client";2 3import React, { useCallback, useEffect, useRef, useState } from "react";4import {5 animate,6 motion,7 useMotionValue,8 useReducedMotion,9 useTransform,10 type AnimationPlaybackControls,11 type MotionValue,12} from "motion/react";13import { cn } from "@/lib/utils";14 15export type SwipeDeckCard = {16 id: string;17 title: string;18 description?: string;19 image?: string;20 accent?: string;21};22 23export type SwipeDirection = "left" | "right";24 25export type SwipeDeckProps = {26 /**27 * Initial cards, top card last. The deck keeps its own list after mount:28 * swiping removes a card, Undo puts it back, Start over restores these.29 */30 cards: SwipeDeckCard[];31 className?: string;32 /** Label for a left swipe. */33 leftLabel?: string;34 /** Label for a right swipe. */35 rightLabel?: string;36 /** Fires with the direction each time a card leaves the deck. */37 onSwipe?: (id: string, direction: SwipeDirection) => void;38 /** Fires when a card leaves the deck, in either direction. */39 onDismiss?: (id: string) => void;40 /** Fires when an undo returns a card to the deck. */41 onUndo?: (id: string) => void;42 /** Fires once when the last card leaves the deck. */43 onEmpty?: () => void;44};45 46const DECEL = 0.998;47const DISMISS_DISTANCE = 120;48const DISMISS_VELOCITY = 550;49/** Drag distance at which the next card is fully promoted. */50const PROMOTE_DISTANCE = 160;51/** Cards visible behind the top one. */52const VISIBLE_DEPTH = 2;53const SETTLE = { type: "spring" as const, bounce: 0, duration: 0.4 };54const FLING = { type: "spring" as const, bounce: 0.12, duration: 0.38 };55 56function project(velocity: number, decelerationRate = DECEL) {57 return ((velocity / 1000) * decelerationRate) / (1 - decelerationRate);58}59 60function rubberband(overshoot: number, dimension: number, constant = 0.55) {61 return (62 (overshoot * dimension * constant) /63 (dimension + constant * Math.abs(overshoot))64 );65}66 67const clamp01 = (v: number) => Math.min(1, Math.max(0, v));68 69type History = { card: SwipeDeckCard; dir: SwipeDirection };70 71export function SwipeDeck({72 cards: initial,73 className,74 leftLabel = "Skip",75 rightLabel = "Save",76 onSwipe,77 onDismiss,78 onUndo,79 onEmpty,80}: SwipeDeckProps) {81 const reduce = useReducedMotion();82 const [cards, setCards] = useState(initial);83 const [history, setHistory] = useState<History[]>([]);84 const [announce, setAnnounce] = useState("");85 const seed = useRef(initial);86 const cardsRef = useRef(cards);87 cardsRef.current = cards;88 89 const callbacks = useRef({ onSwipe, onDismiss, onUndo, onEmpty });90 useEffect(() => {91 callbacks.current = { onSwipe, onDismiss, onUndo, onEmpty };92 }, [onSwipe, onDismiss, onUndo, onEmpty]);93 94 // The top card's offset lives on the deck, so the cards behind it can95 // read the same value and rise into place while it is dragged.96 const topX = useMotionValue(0);97 const topY = useMotionValue(0);98 const stageRef = useRef<HTMLDivElement>(null);99 100 // A flick in flight. A new action commits it immediately instead of101 // waiting, so rapid presses never feel queued.102 const flight = useRef<{103 anims: AnimationPlaybackControls[];104 commit: () => void;105 } | null>(null);106 107 const offscreen = () => (stageRef.current?.offsetWidth ?? 380) + 160;108 109 const commit = useCallback(110 (card: SwipeDeckCard, dir: SwipeDirection) => {111 flight.current = null;112 topX.set(0);113 topY.set(0);114 const next = cardsRef.current.filter((c) => c.id !== card.id);115 cardsRef.current = next;116 setCards(next);117 setHistory((h) => [...h, { card, dir }]);118 const verb = dir === "right" ? rightLabel : leftLabel;119 setAnnounce(120 `${verb}: ${card.title}. ${next.length} ${next.length === 1 ? "card" : "cards"} left.`,121 );122 callbacks.current.onSwipe?.(card.id, dir);123 callbacks.current.onDismiss?.(card.id);124 if (next.length === 0) callbacks.current.onEmpty?.();125 },126 [leftLabel, rightLabel, topX, topY],127 );128 129 const settleFlight = () => {130 const f = flight.current;131 if (!f) return;132 f.anims.forEach((a) => a.stop());133 f.commit();134 };135 136 const fling = useCallback(137 (dir: SwipeDirection, vx = 0, vy = 0) => {138 settleFlight();139 const card = cardsRef.current[cardsRef.current.length - 1];140 if (!card) return;141 if (reduce) {142 commit(card, dir);143 return;144 }145 const sign = dir === "right" ? 1 : -1;146 // Button and keyboard presses get a nudge so the exit still reads as a throw.147 const anims = [148 animate(topX, sign * offscreen(), { ...FLING, velocity: vx || sign * 900 }),149 animate(topY, vy * 0.04, { ...SETTLE, duration: FLING.duration, velocity: vy }),150 ];151 flight.current = { anims, commit: () => commit(card, dir) };152 Promise.all(anims).then(() => {153 if (flight.current?.anims === anims) flight.current.commit();154 });155 },156 // eslint-disable-next-line react-hooks/exhaustive-deps157 [commit, reduce, topX, topY],158 );159 160 const undo = useCallback(() => {161 settleFlight();162 const last = history[history.length - 1];163 if (!last) return;164 setHistory((h) => h.slice(0, -1));165 const next = [...cardsRef.current, last.card];166 cardsRef.current = next;167 setCards(next);168 setAnnounce(`Restored ${last.card.title}.`);169 callbacks.current.onUndo?.(last.card.id);170 if (reduce) return;171 // Return from the side it left; the stack behind recedes as it lands.172 topX.set((last.dir === "right" ? 1 : -1) * offscreen());173 topY.set(0);174 animate(topX, 0, SETTLE);175 // eslint-disable-next-line react-hooks/exhaustive-deps176 }, [history, reduce, topX, topY]);177 178 const restart = () => {179 settleFlight();180 cardsRef.current = seed.current;181 setCards(seed.current);182 setHistory([]);183 setAnnounce("Deck restored.");184 topX.set(0);185 topY.set(0);186 };187 188 /* ── Drag ─────────────────────────────────────────────── */189 190 const dragging = useRef(false);191 const start = useRef({ x: 0, y: 0 });192 const samples = useRef<{ t: number; x: number; y: number }[]>([]);193 194 const velocity = () => {195 const s = samples.current;196 if (s.length < 2) return { vx: 0, vy: 0 };197 const a = s[0];198 const b = s[s.length - 1];199 const dt = (b.t - a.t) / 1000;200 if (dt <= 0) return { vx: 0, vy: 0 };201 return { vx: (b.x - a.x) / dt, vy: (b.y - a.y) / dt };202 };203 204 const onPointerDown = (e: React.PointerEvent<HTMLElement>) => {205 if (reduce || !e.isPrimary || e.button !== 0 || flight.current) return;206 dragging.current = true;207 topX.stop();208 topY.stop();209 // Grabbing mid-settle continues from the live offset instead of jumping.210 start.current = { x: e.clientX - topX.get(), y: e.clientY - topY.get() / 0.28 };211 samples.current = [{ t: performance.now(), x: e.clientX, y: e.clientY }];212 e.currentTarget.setPointerCapture(e.pointerId);213 };214 215 const onPointerMove = (e: React.PointerEvent<HTMLElement>) => {216 if (!dragging.current) return;217 let nextX = e.clientX - start.current.x;218 const max = 260;219 if (Math.abs(nextX) > max) {220 nextX = Math.sign(nextX) * (max + rubberband(Math.abs(nextX) - max, max));221 }222 topX.set(nextX);223 topY.set((e.clientY - start.current.y) * 0.28);224 samples.current.push({ t: performance.now(), x: e.clientX, y: e.clientY });225 if (samples.current.length > 5) samples.current.shift();226 };227 228 const onPointerUp = () => {229 if (!dragging.current) return;230 dragging.current = false;231 const { vx, vy } = velocity();232 const projected = topX.get() + project(vx);233 if (Math.abs(projected) > DISMISS_DISTANCE || Math.abs(vx) > DISMISS_VELOCITY) {234 fling(projected >= 0 ? "right" : "left", vx, vy);235 } else {236 animate(topX, 0, { ...SETTLE, velocity: vx });237 animate(topY, 0, { ...SETTLE, velocity: vy });238 }239 };240 241 // The browser took over (vertical page scroll via pan-y): put the card back.242 const onPointerCancel = () => {243 if (!dragging.current) return;244 dragging.current = false;245 animate(topX, 0, SETTLE);246 animate(topY, 0, SETTLE);247 };248 249 const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {250 if (e.key === "ArrowLeft") {251 e.preventDefault();252 fling("left");253 } else if (e.key === "ArrowRight") {254 e.preventDefault();255 fling("right");256 } else if (e.key === "Backspace" || (e.key === "z" && (e.metaKey || e.ctrlKey))) {257 e.preventDefault();258 undo();259 }260 };261 262 useEffect(263 () => () => flight.current?.anims.forEach((a) => a.stop()),264 [],265 );266 267 const total = seed.current.length;268 const done = total - cards.length;269 const empty = cards.length === 0;270 const saved = history.filter((h) => h.dir === "right").map((h) => h.card);271 272 return (273 <div className={cn("relative mx-auto w-full max-w-[380px]", className)}>274 {/* Progress */}275 <div className="mb-3 flex items-center justify-between px-1 text-[12px] text-(--color-neutral)">276 <span className="font-mono tabular-nums">277 {String(Math.min(done + 1, total)).padStart(2, "0")}278 <span className="opacity-50"> / {String(total).padStart(2, "0")}</span>279 </span>280 <span aria-hidden className="flex gap-1">281 {seed.current.map((c, i) => (282 <span283 key={c.id}284 className={cn(285 "h-1 rounded-full transition-[width,background-color] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)]",286 i < done287 ? "w-1.5 bg-(--color-ink)/35"288 : i === done289 ? "w-4 bg-(--color-ink)"290 : "w-1.5 bg-(--color-rule)",291 )}292 />293 ))}294 </span>295 </div>296 297 <div298 ref={stageRef}299 role="group"300 aria-roledescription="card deck"301 aria-label={`Card deck. Use left and right arrows to ${leftLabel.toLowerCase()} or ${rightLabel.toLowerCase()}, Backspace to undo.`}302 tabIndex={empty ? -1 : 0}303 onKeyDown={onKeyDown}304 className="relative grid w-full rounded-lg pt-8 outline-none focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-4 focus-visible:ring-offset-(--color-paper)"305 >306 {empty ? (307 <DeckSummary308 saved={saved}309 total={total}310 rightLabel={rightLabel}311 onRestart={restart}312 />313 ) : (314 cards.map((card, i) => {315 const depth = cards.length - 1 - i;316 if (depth > VISIBLE_DEPTH + 1) return null;317 return (318 <DeckCard319 key={card.id}320 card={card}321 depth={depth}322 topX={topX}323 topY={topY}324 leftLabel={leftLabel}325 rightLabel={rightLabel}326 handlers={327 depth === 0328 ? { onPointerDown, onPointerMove, onPointerUp, onPointerCancel }329 : undefined330 }331 />332 );333 })334 )}335 </div>336 337 {/* Controls */}338 <div className="mt-5 flex items-center justify-center gap-4">339 <ActionButton340 label={leftLabel}341 tone="skip"342 topX={topX}343 side={-1}344 disabled={empty}345 onClick={() => fling("left")}346 >347 <svg viewBox="0 0 24 24" className="size-5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">348 <path d="M6 6l12 12M18 6L6 18" />349 </svg>350 </ActionButton>351 352 <button353 type="button"354 onClick={undo}355 disabled={history.length === 0}356 aria-label="Undo"357 className={cn(358 "grid size-10 place-items-center rounded-full",359 "border border-(--color-rule) bg-(--color-paper) text-(--color-ink-2)",360 "transition-[transform,opacity,background-color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]",361 "hover:bg-(--color-paper-2) active:scale-[0.94] disabled:opacity-35 disabled:active:scale-100",362 "outline-none focus-visible:ring-2 focus-visible:ring-(--color-focus)",363 )}364 >365 <svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round">366 <path d="M9 14L4 9l5-5" />367 <path d="M4 9h10.5a5.5 5.5 0 010 11H11" />368 </svg>369 </button>370 371 <ActionButton372 label={rightLabel}373 tone="save"374 topX={topX}375 side={1}376 disabled={empty}377 onClick={() => fling("right")}378 >379 <svg viewBox="0 0 24 24" className="size-5" fill="currentColor">380 <path d="M12 20.5s-7.5-4.6-7.5-10.2A4.3 4.3 0 0112 7.6a4.3 4.3 0 017.5 2.7c0 5.6-7.5 10.2-7.5 10.2z" />381 </svg>382 </ActionButton>383 </div>384 385 <span className="sr-only" role="status" aria-live="polite">386 {announce}387 </span>388 </div>389 );390}391 392/* ── Card ────────────────────────────────────────────────── */393 394type PointerHandlers = {395 onPointerDown: React.PointerEventHandler<HTMLElement>;396 onPointerMove: React.PointerEventHandler<HTMLElement>;397 onPointerUp: React.PointerEventHandler<HTMLElement>;398 onPointerCancel: React.PointerEventHandler<HTMLElement>;399};400 401function DeckCard({402 card,403 depth,404 topX,405 topY,406 leftLabel,407 rightLabel,408 handlers,409}: {410 card: SwipeDeckCard;411 depth: number;412 topX: MotionValue<number>;413 topY: MotionValue<number>;414 leftLabel: string;415 rightLabel: string;416 handlers?: PointerHandlers;417}) {418 const active = depth === 0;419 420 // Cards behind rise by however far the top card has travelled. At full421 // promotion they sit exactly where they will be after the swap, so the422 // hand-off when the top card is removed has no jump.423 const promote = useTransform(topX, (v) => clamp01(Math.abs(v) / PROMOTE_DISTANCE));424 const slot = useTransform(promote, (p) => Math.max(0, depth - p));425 const stackScale = useTransform(slot, (s) => 1 - s * 0.06);426 const stackY = useTransform(slot, (s) => -s * 14);427 const stackOpacity = useTransform(slot, (s) => clamp01(VISIBLE_DEPTH + 1 - s));428 429 const rotate = useTransform(topX, [-260, 0, 260], [-12, 0, 12]);430 const flyOpacity = useTransform(topX, [-420, -260, 0, 260, 420], [0, 1, 1, 1, 0]);431 const rightStamp = useTransform(topX, [24, DISMISS_DISTANCE], [0, 1]);432 const leftStamp = useTransform(topX, [-DISMISS_DISTANCE, -24], [1, 0]);433 // Shadow lifts while held away from the stack434 const shadow = useTransform(promote, (p) =>435 `0 1px 2px rgba(0,0,0,0.04), 0 ${10 + p * 14}px ${28 + p * 24}px rgba(0,0,0,${(0.07 + p * 0.06).toFixed(3)})`,436 );437 438 return (439 <motion.article440 {...handlers}441 aria-hidden={!active}442 style={{443 x: active ? topX : 0,444 y: active ? topY : stackY,445 rotate: active ? rotate : 0,446 scale: active ? 1 : stackScale,447 opacity: active ? flyOpacity : stackOpacity,448 boxShadow: active ? shadow : undefined,449 zIndex: 10 - depth,450 transformOrigin: "50% 0%",451 }}452 className={cn(453 "relative mx-auto w-full self-end overflow-hidden select-none [grid-area:1/1]",454 // pan-y keeps vertical page scroll on touch; the deck only claims horizontal moves.455 active ? "cursor-grab touch-pan-y active:cursor-grabbing" : "pointer-events-none",456 "rounded-lg bg-(--color-paper) outline outline-1 -outline-offset-1 outline-black/8 dark:outline-white/10",457 !active && "shadow-[0_1px_2px_rgba(0,0,0,0.04),0_8px_20px_rgba(0,0,0,0.05)]",458 "dark:shadow-[0_12px_32px_rgba(0,0,0,0.4)]",459 )}460 >461 <div className="relative aspect-16/10 overflow-hidden bg-(--color-paper-2)">462 {card.image ? (463 // eslint-disable-next-line @next/next/no-img-element464 <img465 src={card.image}466 alt=""467 draggable={false}468 className="h-full w-full object-cover object-top"469 />470 ) : (471 <div472 className="h-full w-full"473 style={{474 background:475 card.accent ??476 "linear-gradient(145deg, var(--color-paper-2) 0%, var(--color-rule) 100%)",477 }}478 />479 )}480 481 {active ? (482 <>483 <Stamp label={rightLabel} tone="save" opacity={rightStamp} className="left-3" />484 <Stamp label={leftLabel} tone="skip" opacity={leftStamp} className="right-3" />485 </>486 ) : null}487 </div>488 489 <div className="flex flex-col gap-1 px-5 pt-4 pb-5">490 <div className="truncate font-(family-name:--font-display) text-[17px] font-medium tracking-[-0.02em] text-(--color-ink)">491 {card.title}492 </div>493 {card.description ? (494 <div className="line-clamp-2 text-[13px] leading-relaxed text-pretty text-(--color-ink-2)">495 {card.description}496 </div>497 ) : null}498 </div>499 </motion.article>500 );501}502 503function Stamp({504 label,505 tone,506 opacity,507 className,508}: {509 label: string;510 tone: "save" | "skip";511 opacity: MotionValue<number>;512 className?: string;513}) {514 const scale = useTransform(opacity, [0, 1], [0.85, 1]);515 return (516 <motion.span517 aria-hidden518 style={{ opacity, scale }}519 className={cn(520 "absolute top-3 inline-flex h-7 items-center rounded-full px-3",521 "text-[11px] font-semibold tracking-[0.08em] uppercase",522 "backdrop-blur-md shadow-[0_4px_12px_rgba(0,0,0,0.12)]",523 tone === "save" ? "bg-emerald-500/90 text-white" : "bg-black/70 text-white",524 className,525 )}526 >527 {label}528 </motion.span>529 );530}531 532/* ── Controls ────────────────────────────────────────────── */533 534function ActionButton({535 label,536 tone,537 topX,538 side,539 disabled,540 onClick,541 children,542}: {543 label: string;544 tone: "save" | "skip";545 topX: MotionValue<number>;546 side: 1 | -1;547 disabled: boolean;548 onClick: () => void;549 children: React.ReactNode;550}) {551 // The matching button swells as a drag heads its way.552 const lean = useTransform(topX, (v) => clamp01((v * side) / DISMISS_DISTANCE));553 const scale = useTransform(lean, [0, 1], [1, 1.12]);554 const fill = useTransform(lean, [0, 1], [0, 1]);555 556 return (557 <motion.button558 type="button"559 onClick={onClick}560 disabled={disabled}561 aria-label={label}562 style={{ scale }}563 whileTap={{ scale: 0.92 }}564 className={cn(565 "relative grid size-14 place-items-center overflow-hidden rounded-full",566 "border border-(--color-rule) bg-(--color-paper)",567 "shadow-[0_1px_2px_rgba(0,0,0,0.04),0_6px_16px_rgba(0,0,0,0.06)]",568 "outline-none focus-visible:ring-2 focus-visible:ring-(--color-focus) disabled:opacity-35",569 tone === "save" ? "text-emerald-600 dark:text-emerald-400" : "text-(--color-ink-2)",570 )}571 >572 <motion.span573 aria-hidden574 style={{ opacity: fill }}575 className={cn(576 "absolute inset-0",577 tone === "save" ? "bg-emerald-500/12" : "bg-(--color-ink)/6",578 )}579 />580 <span className="relative">{children}</span>581 </motion.button>582 );583}584 585/* ── End state ───────────────────────────────────────────── */586 587function DeckSummary({588 saved,589 total,590 rightLabel,591 onRestart,592}: {593 saved: SwipeDeckCard[];594 total: number;595 rightLabel: string;596 onRestart: () => void;597}) {598 return (599 <motion.div600 initial={{ opacity: 0, scale: 0.97 }}601 animate={{ opacity: 1, scale: 1 }}602 transition={{ duration: 0.25, ease: [0.23, 1, 0.32, 1] }}603 className="flex min-h-[300px] flex-col items-center justify-center gap-4 rounded-lg border border-dashed [grid-area:1/1] border-(--color-rule) bg-(--color-paper-2)/40 px-6 text-center"604 >605 {saved.length > 0 ? (606 <div className="flex -space-x-3">607 {saved.slice(0, 4).map((card, i) => (608 <span609 key={card.id}610 className="size-11 overflow-hidden rounded-full bg-(--color-paper-2) ring-2 ring-(--color-paper)"611 style={{ zIndex: 4 - i }}612 >613 {card.image ? (614 // eslint-disable-next-line @next/next/no-img-element615 <img src={card.image} alt="" className="h-full w-full object-cover object-top" />616 ) : (617 <span className="block h-full w-full" style={{ background: card.accent }} />618 )}619 </span>620 ))}621 </div>622 ) : null}623 624 <div>625 <div className="text-[15px] font-medium text-(--color-ink)">All caught up</div>626 <div className="mt-1 text-[13px] text-(--color-neutral)">627 {rightLabel}: {saved.length} of {total}628 </div>629 </div>630 631 <button632 type="button"633 onClick={onRestart}634 className="min-h-9 rounded-full border border-(--color-rule) bg-(--color-paper) px-4 text-[13px] font-medium text-(--color-ink) transition-transform duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] hover:bg-(--color-paper-2) active:scale-[0.96]"635 >636 Start over637 </button>638 </motion.div>639 );640}
Adjust import paths according to your folder structure.
Basic Usage
tsx
1import { SwipeDeck } from "@/components/ui/swipe-deck";2 3<SwipeDeck4 cards={[5 { id: "1", title: "Signal", description: "Flick to dismiss." },6 { id: "2", title: "Momentum", description: "Velocity wins." },7 ]}8/>
Props
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| cards | SwipeDeckCard[] | Yes | — | Deck cards — id, title, optional description, image, and accent. |
| leftLabel | string | No | "Skip" | Label for a left swipe, shown on the stamp and button. |
| rightLabel | string | No | "Save" | Label for a right swipe, shown on the stamp and button. |
| onSwipe | (id: string, direction: "left" | "right") => void | No | — | Fires with the direction each time a card leaves the deck. |
| onDismiss | (id: string) => void | No | — | Fires when a card leaves the deck, in either direction. |
| onUndo | (id: string) => void | No | — | Fires when Undo returns a card to the deck. |
| onEmpty | () => void | No | — | Fires when the last card leaves the deck. |
| className | string | No | — | Optional class names on the deck wrapper. |
Best Practices
- Project the resting point from velocity — never snap only from release position.
- Let a fast flick win even below the distance threshold.
- Hand velocity into the spring so drag and fling share one seam.
- Let the stack respond to the drag so the hand-off to the next card has no jump.
- Give every gesture a button and a key: swipe, Skip/Save buttons, arrows, Backspace to undo.
- Under
prefers-reduced-motion, disable drag; the buttons and keys still work, without the flight.