A draggable card stack for triage with flick-velocity commits, rubber-band snap-back, and arrow-key fallback.
npx shadcn@latest add @paragon/swipe-card-deck"use client";
import * as React from "react";
import {
AnimatePresence,
motion,
useMotionValue,
useReducedMotion,
useTransform,
} from "motion/react";
import { cn } from "@/lib/utils";
/** Flick speed (px/ms) that commits a swipe regardless of distance. */
const FLICK_VELOCITY = 0.35;
/** Horizontal travel (px) past which a release commits. */
const COMMIT_DISTANCE = 120;
export type SwipeDirection = "left" | "right";
export interface SwipeCard {
id: string | number;
content: React.ReactNode;
}
export interface SwipeCardDeckProps
extends Omit<React.ComponentProps<"div">, "onChange"> {
cards: SwipeCard[];
/** Fires when the top card leaves in a direction. */
onSwipe?: (id: SwipeCard["id"], direction: SwipeDirection) => void;
/** Called when the deck empties. */
onEmpty?: () => void;
/** Labels for the left/right accept-reject hint overlays. */
labels?: { left: string; right: string };
static?: boolean;
}
/**
* A Tinder-style triage stack. The top card is draggable; drag rotates it and
* fades in a left/right hint; releasing past ~120px or on a flick commits and
* flings the card off, otherwise it rubber-bands home. Below it two cards peek
* with a scale/offset. Full keyboard fallback: ArrowLeft/ArrowRight dismiss
* the top card. Pointer is captured by motion's drag; reduced motion skips the
* fling and snaps.
*/
export function SwipeCardDeck({
cards,
onSwipe,
onEmpty,
labels = { left: "Pass", right: "Keep" },
static: isStatic = false,
className,
...props
}: SwipeCardDeckProps) {
const reduced = useReducedMotion() ?? false;
const [index, setIndex] = React.useState(0);
const [flingDir, setFlingDir] = React.useState<SwipeDirection | null>(null);
const remaining = cards.slice(index, index + 3);
const topCard = remaining[0];
const commit = (direction: SwipeDirection) => {
if (!topCard) return;
onSwipe?.(topCard.id, direction);
setFlingDir(direction);
setIndex((i) => {
const next = i + 1;
if (next >= cards.length) onEmpty?.();
return next;
});
};
return (
<div
role="group"
aria-label="Card deck — use arrow keys to sort"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "ArrowLeft") {
e.preventDefault();
commit("left");
} else if (e.key === "ArrowRight") {
e.preventDefault();
commit("right");
}
}}
className={cn(
"relative h-72 w-full max-w-xs outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
className,
)}
{...props}
>
{remaining.length === 0 && (
<div className="flex size-full items-center justify-center rounded-2xl bg-card text-sm text-muted-foreground shadow-border">
All caught up
</div>
)}
{/* Under-cards, back to front. */}
{remaining
.map((card, i) => ({ card, i }))
.reverse()
.map(({ card, i }) => {
if (i === 0) return null;
return (
<div
key={card.id}
aria-hidden
className="absolute inset-0 rounded-2xl bg-card shadow-border"
style={{
transform: `translateY(${i * 10}px) scale(${1 - i * 0.05})`,
zIndex: 10 - i,
}}
>
<div className="pointer-events-none size-full opacity-60">
{card.content}
</div>
</div>
);
})}
<AnimatePresence>
{topCard && (
<TopCard
key={topCard.id}
card={topCard}
labels={labels}
flingDir={flingDir}
reduced={reduced || isStatic}
onCommit={commit}
/>
)}
</AnimatePresence>
</div>
);
}
function TopCard({
card,
labels,
flingDir,
reduced,
onCommit,
}: {
card: SwipeCard;
labels: { left: string; right: string };
flingDir: SwipeDirection | null;
reduced: boolean;
onCommit: (direction: SwipeDirection) => void;
}) {
const x = useMotionValue(0);
const rotate = useTransform(x, [-200, 200], [-12, 12]);
const rightHint = useTransform(x, [20, 120], [0, 1]);
const leftHint = useTransform(x, [-120, -20], [1, 0]);
return (
<motion.div
className="absolute inset-0 z-20 cursor-grab touch-none rounded-2xl bg-card shadow-border active:cursor-grabbing"
style={{ x, rotate }}
drag={reduced ? false : "x"}
dragElastic={0.5}
dragConstraints={{ left: 0, right: 0 }}
onDragEnd={(_, info) => {
const flung =
Math.abs(info.velocity.x) > FLICK_VELOCITY * 1000 ||
Math.abs(info.offset.x) > COMMIT_DISTANCE;
if (flung) onCommit(info.offset.x > 0 ? "right" : "left");
}}
initial={false}
exit={
reduced
? { opacity: 0, transition: { duration: 0.15 } }
: {
x: (flingDir === "left" ? -1 : 1) * 460,
rotate: (flingDir === "left" ? -1 : 1) * 22,
opacity: 0,
transition: { duration: 0.3, ease: [0.4, 0, 1, 1] },
}
}
>
<div className="pointer-events-none size-full">{card.content}</div>
{/* Direction hints. */}
<motion.span
aria-hidden
style={{ opacity: reduced ? 0 : rightHint }}
className="pointer-events-none absolute left-4 top-4 rotate-[-8deg] rounded-md border-2 border-success px-2 py-0.5 text-sm font-bold uppercase tracking-wide text-success"
>
{labels.right}
</motion.span>
<motion.span
aria-hidden
style={{ opacity: reduced ? 0 : leftHint }}
className="pointer-events-none absolute right-4 top-4 rotate-[8deg] rounded-md border-2 border-destructive px-2 py-0.5 text-sm font-bold uppercase tracking-wide text-destructive"
>
{labels.left}
</motion.span>
</motion.div>
);
}