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

Install dependencies:

$ pnpm add motion

Add the utility function for class merging:

lib/utils.ts
tsx
import { ClassValue, clsx } from "clsx";import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {  return twMerge(clsx(inputs));}

Copy the component code into your project:

components/ui/swipe-deck.tsx
tsx
"use client";
import React, { useCallback, useEffect, useRef, useState } from "react";import {  animate,  motion,  useMotionValue,  useReducedMotion,  useTransform,  type AnimationPlaybackControls,  type MotionValue,} from "motion/react";import { cn } from "@/lib/utils";
export type SwipeDeckCard = {  id: string;  title: string;  description?: string;  image?: string;  accent?: string;};
export type SwipeDirection = "left" | "right";
export type SwipeDeckProps = {  /**   * Initial cards, top card last. The deck keeps its own list after mount:   * swiping removes a card, Undo puts it back, Start over restores these.   */  cards: SwipeDeckCard[];  className?: string;  /** Label for a left swipe. */  leftLabel?: string;  /** Label for a right swipe. */  rightLabel?: string;  /** Fires with the direction each time a card leaves the deck. */  onSwipe?: (id: string, direction: SwipeDirection) => void;  /** Fires when a card leaves the deck, in either direction. */  onDismiss?: (id: string) => void;  /** Fires when an undo returns a card to the deck. */  onUndo?: (id: string) => void;  /** Fires once when the last card leaves the deck. */  onEmpty?: () => void;};
const DECEL = 0.998;const DISMISS_DISTANCE = 120;const DISMISS_VELOCITY = 550;/** Drag distance at which the next card is fully promoted. */const PROMOTE_DISTANCE = 160;/** Cards visible behind the top one. */const VISIBLE_DEPTH = 2;const SETTLE = { type: "spring" as const, bounce: 0, duration: 0.4 };const FLING = { type: "spring" as const, bounce: 0.12, duration: 0.38 };
function project(velocity: number, decelerationRate = DECEL) {  return ((velocity / 1000) * decelerationRate) / (1 - decelerationRate);}
function rubberband(overshoot: number, dimension: number, constant = 0.55) {  return (    (overshoot * dimension * constant) /    (dimension + constant * Math.abs(overshoot))  );}
const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
type History = { card: SwipeDeckCard; dir: SwipeDirection };
export function SwipeDeck({  cards: initial,  className,  leftLabel = "Skip",  rightLabel = "Save",  onSwipe,  onDismiss,  onUndo,  onEmpty,}: SwipeDeckProps) {  const reduce = useReducedMotion();  const [cards, setCards] = useState(initial);  const [history, setHistory] = useState<History[]>([]);  const [announce, setAnnounce] = useState("");  const seed = useRef(initial);  const cardsRef = useRef(cards);  cardsRef.current = cards;
  const callbacks = useRef({ onSwipe, onDismiss, onUndo, onEmpty });  useEffect(() => {    callbacks.current = { onSwipe, onDismiss, onUndo, onEmpty };  }, [onSwipe, onDismiss, onUndo, onEmpty]);
  // The top card's offset lives on the deck, so the cards behind it can  // read the same value and rise into place while it is dragged.  const topX = useMotionValue(0);  const topY = useMotionValue(0);  const stageRef = useRef<HTMLDivElement>(null);
  // A flick in flight. A new action commits it immediately instead of  // waiting, so rapid presses never feel queued.  const flight = useRef<{    anims: AnimationPlaybackControls[];    commit: () => void;  } | null>(null);
  const offscreen = () => (stageRef.current?.offsetWidth ?? 380) + 160;
  const commit = useCallback(    (card: SwipeDeckCard, dir: SwipeDirection) => {      flight.current = null;      topX.set(0);      topY.set(0);      const next = cardsRef.current.filter((c) => c.id !== card.id);      cardsRef.current = next;      setCards(next);      setHistory((h) => [...h, { card, dir }]);      const verb = dir === "right" ? rightLabel : leftLabel;      setAnnounce(        `${verb}: ${card.title}. ${next.length} ${next.length === 1 ? "card" : "cards"} left.`,      );      callbacks.current.onSwipe?.(card.id, dir);      callbacks.current.onDismiss?.(card.id);      if (next.length === 0) callbacks.current.onEmpty?.();    },    [leftLabel, rightLabel, topX, topY],  );
  const settleFlight = () => {    const f = flight.current;    if (!f) return;    f.anims.forEach((a) => a.stop());    f.commit();  };
  const fling = useCallback(    (dir: SwipeDirection, vx = 0, vy = 0) => {      settleFlight();      const card = cardsRef.current[cardsRef.current.length - 1];      if (!card) return;      if (reduce) {        commit(card, dir);        return;      }      const sign = dir === "right" ? 1 : -1;      // Button and keyboard presses get a nudge so the exit still reads as a throw.      const anims = [        animate(topX, sign * offscreen(), { ...FLING, velocity: vx || sign * 900 }),        animate(topY, vy * 0.04, { ...SETTLE, duration: FLING.duration, velocity: vy }),      ];      flight.current = { anims, commit: () => commit(card, dir) };      Promise.all(anims).then(() => {        if (flight.current?.anims === anims) flight.current.commit();      });    },    // eslint-disable-next-line react-hooks/exhaustive-deps    [commit, reduce, topX, topY],  );
  const undo = useCallback(() => {    settleFlight();    const last = history[history.length - 1];    if (!last) return;    setHistory((h) => h.slice(0, -1));    const next = [...cardsRef.current, last.card];    cardsRef.current = next;    setCards(next);    setAnnounce(`Restored ${last.card.title}.`);    callbacks.current.onUndo?.(last.card.id);    if (reduce) return;    // Return from the side it left; the stack behind recedes as it lands.    topX.set((last.dir === "right" ? 1 : -1) * offscreen());    topY.set(0);    animate(topX, 0, SETTLE);    // eslint-disable-next-line react-hooks/exhaustive-deps  }, [history, reduce, topX, topY]);
  const restart = () => {    settleFlight();    cardsRef.current = seed.current;    setCards(seed.current);    setHistory([]);    setAnnounce("Deck restored.");    topX.set(0);    topY.set(0);  };
  /* ── Drag ─────────────────────────────────────────────── */
  const dragging = useRef(false);  const start = useRef({ x: 0, y: 0 });  const samples = useRef<{ t: number; x: number; y: number }[]>([]);
  const velocity = () => {    const s = samples.current;    if (s.length < 2) return { vx: 0, vy: 0 };    const a = s[0];    const b = s[s.length - 1];    const dt = (b.t - a.t) / 1000;    if (dt <= 0) return { vx: 0, vy: 0 };    return { vx: (b.x - a.x) / dt, vy: (b.y - a.y) / dt };  };
  const onPointerDown = (e: React.PointerEvent<HTMLElement>) => {    if (reduce || !e.isPrimary || e.button !== 0 || flight.current) return;    dragging.current = true;    topX.stop();    topY.stop();    // Grabbing mid-settle continues from the live offset instead of jumping.    start.current = { x: e.clientX - topX.get(), y: e.clientY - topY.get() / 0.28 };    samples.current = [{ t: performance.now(), x: e.clientX, y: e.clientY }];    e.currentTarget.setPointerCapture(e.pointerId);  };
  const onPointerMove = (e: React.PointerEvent<HTMLElement>) => {    if (!dragging.current) return;    let nextX = e.clientX - start.current.x;    const max = 260;    if (Math.abs(nextX) > max) {      nextX = Math.sign(nextX) * (max + rubberband(Math.abs(nextX) - max, max));    }    topX.set(nextX);    topY.set((e.clientY - start.current.y) * 0.28);    samples.current.push({ t: performance.now(), x: e.clientX, y: e.clientY });    if (samples.current.length > 5) samples.current.shift();  };
  const onPointerUp = () => {    if (!dragging.current) return;    dragging.current = false;    const { vx, vy } = velocity();    const projected = topX.get() + project(vx);    if (Math.abs(projected) > DISMISS_DISTANCE || Math.abs(vx) > DISMISS_VELOCITY) {      fling(projected >= 0 ? "right" : "left", vx, vy);    } else {      animate(topX, 0, { ...SETTLE, velocity: vx });      animate(topY, 0, { ...SETTLE, velocity: vy });    }  };
  // The browser took over (vertical page scroll via pan-y): put the card back.  const onPointerCancel = () => {    if (!dragging.current) return;    dragging.current = false;    animate(topX, 0, SETTLE);    animate(topY, 0, SETTLE);  };
  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {    if (e.key === "ArrowLeft") {      e.preventDefault();      fling("left");    } else if (e.key === "ArrowRight") {      e.preventDefault();      fling("right");    } else if (e.key === "Backspace" || (e.key === "z" && (e.metaKey || e.ctrlKey))) {      e.preventDefault();      undo();    }  };
  useEffect(    () => () => flight.current?.anims.forEach((a) => a.stop()),    [],  );
  const total = seed.current.length;  const done = total - cards.length;  const empty = cards.length === 0;  const saved = history.filter((h) => h.dir === "right").map((h) => h.card);
  return (    <div className={cn("relative mx-auto w-full max-w-[380px]", className)}>      {/* Progress */}      <div className="mb-3 flex items-center justify-between px-1 text-[12px] text-(--color-neutral)">        <span className="font-mono tabular-nums">          {String(Math.min(done + 1, total)).padStart(2, "0")}          <span className="opacity-50"> / {String(total).padStart(2, "0")}</span>        </span>        <span aria-hidden className="flex gap-1">          {seed.current.map((c, i) => (            <span              key={c.id}              className={cn(                "h-1 rounded-full transition-[width,background-color] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)]",                i < done                  ? "w-1.5 bg-(--color-ink)/35"                  : i === done                    ? "w-4 bg-(--color-ink)"                    : "w-1.5 bg-(--color-rule)",              )}            />          ))}        </span>      </div>
      <div        ref={stageRef}        role="group"        aria-roledescription="card deck"        aria-label={`Card deck. Use left and right arrows to ${leftLabel.toLowerCase()} or ${rightLabel.toLowerCase()}, Backspace to undo.`}        tabIndex={empty ? -1 : 0}        onKeyDown={onKeyDown}        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)"      >        {empty ? (          <DeckSummary            saved={saved}            total={total}            rightLabel={rightLabel}            onRestart={restart}          />        ) : (          cards.map((card, i) => {            const depth = cards.length - 1 - i;            if (depth > VISIBLE_DEPTH + 1) return null;            return (              <DeckCard                key={card.id}                card={card}                depth={depth}                topX={topX}                topY={topY}                leftLabel={leftLabel}                rightLabel={rightLabel}                handlers={                  depth === 0                    ? { onPointerDown, onPointerMove, onPointerUp, onPointerCancel }                    : undefined                }              />            );          })        )}      </div>
      {/* Controls */}      <div className="mt-5 flex items-center justify-center gap-4">        <ActionButton          label={leftLabel}          tone="skip"          topX={topX}          side={-1}          disabled={empty}          onClick={() => fling("left")}        >          <svg viewBox="0 0 24 24" className="size-5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">            <path d="M6 6l12 12M18 6L6 18" />          </svg>        </ActionButton>
        <button          type="button"          onClick={undo}          disabled={history.length === 0}          aria-label="Undo"          className={cn(            "grid size-10 place-items-center rounded-full",            "border border-(--color-rule) bg-(--color-paper) text-(--color-ink-2)",            "transition-[transform,opacity,background-color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]",            "hover:bg-(--color-paper-2) active:scale-[0.94] disabled:opacity-35 disabled:active:scale-100",            "outline-none focus-visible:ring-2 focus-visible:ring-(--color-focus)",          )}        >          <svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round">            <path d="M9 14L4 9l5-5" />            <path d="M4 9h10.5a5.5 5.5 0 010 11H11" />          </svg>        </button>
        <ActionButton          label={rightLabel}          tone="save"          topX={topX}          side={1}          disabled={empty}          onClick={() => fling("right")}        >          <svg viewBox="0 0 24 24" className="size-5" fill="currentColor">            <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" />          </svg>        </ActionButton>      </div>
      <span className="sr-only" role="status" aria-live="polite">        {announce}      </span>    </div>  );}
/* ── Card ────────────────────────────────────────────────── */
type PointerHandlers = {  onPointerDown: React.PointerEventHandler<HTMLElement>;  onPointerMove: React.PointerEventHandler<HTMLElement>;  onPointerUp: React.PointerEventHandler<HTMLElement>;  onPointerCancel: React.PointerEventHandler<HTMLElement>;};
function DeckCard({  card,  depth,  topX,  topY,  leftLabel,  rightLabel,  handlers,}: {  card: SwipeDeckCard;  depth: number;  topX: MotionValue<number>;  topY: MotionValue<number>;  leftLabel: string;  rightLabel: string;  handlers?: PointerHandlers;}) {  const active = depth === 0;
  // Cards behind rise by however far the top card has travelled. At full  // promotion they sit exactly where they will be after the swap, so the  // hand-off when the top card is removed has no jump.  const promote = useTransform(topX, (v) => clamp01(Math.abs(v) / PROMOTE_DISTANCE));  const slot = useTransform(promote, (p) => Math.max(0, depth - p));  const stackScale = useTransform(slot, (s) => 1 - s * 0.06);  const stackY = useTransform(slot, (s) => -s * 14);  const stackOpacity = useTransform(slot, (s) => clamp01(VISIBLE_DEPTH + 1 - s));
  const rotate = useTransform(topX, [-260, 0, 260], [-12, 0, 12]);  const flyOpacity = useTransform(topX, [-420, -260, 0, 260, 420], [0, 1, 1, 1, 0]);  const rightStamp = useTransform(topX, [24, DISMISS_DISTANCE], [0, 1]);  const leftStamp = useTransform(topX, [-DISMISS_DISTANCE, -24], [1, 0]);  // Shadow lifts while held away from the stack  const shadow = useTransform(promote, (p) =>    `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)})`,  );
  return (    <motion.article      {...handlers}      aria-hidden={!active}      style={{        x: active ? topX : 0,        y: active ? topY : stackY,        rotate: active ? rotate : 0,        scale: active ? 1 : stackScale,        opacity: active ? flyOpacity : stackOpacity,        boxShadow: active ? shadow : undefined,        zIndex: 10 - depth,        transformOrigin: "50% 0%",      }}      className={cn(        "relative mx-auto w-full self-end overflow-hidden select-none [grid-area:1/1]",        // pan-y keeps vertical page scroll on touch; the deck only claims horizontal moves.        active ? "cursor-grab touch-pan-y active:cursor-grabbing" : "pointer-events-none",        "rounded-lg bg-(--color-paper) outline outline-1 -outline-offset-1 outline-black/8 dark:outline-white/10",        !active && "shadow-[0_1px_2px_rgba(0,0,0,0.04),0_8px_20px_rgba(0,0,0,0.05)]",        "dark:shadow-[0_12px_32px_rgba(0,0,0,0.4)]",      )}    >      <div className="relative aspect-16/10 overflow-hidden bg-(--color-paper-2)">        {card.image ? (          // eslint-disable-next-line @next/next/no-img-element          <img            src={card.image}            alt=""            draggable={false}            className="h-full w-full object-cover object-top"          />        ) : (          <div            className="h-full w-full"            style={{              background:                card.accent ??                "linear-gradient(145deg, var(--color-paper-2) 0%, var(--color-rule) 100%)",            }}          />        )}
        {active ? (          <>            <Stamp label={rightLabel} tone="save" opacity={rightStamp} className="left-3" />            <Stamp label={leftLabel} tone="skip" opacity={leftStamp} className="right-3" />          </>        ) : null}      </div>
      <div className="flex flex-col gap-1 px-5 pt-4 pb-5">        <div className="truncate font-(family-name:--font-display) text-[17px] font-medium tracking-[-0.02em] text-(--color-ink)">          {card.title}        </div>        {card.description ? (          <div className="line-clamp-2 text-[13px] leading-relaxed text-pretty text-(--color-ink-2)">            {card.description}          </div>        ) : null}      </div>    </motion.article>  );}
function Stamp({  label,  tone,  opacity,  className,}: {  label: string;  tone: "save" | "skip";  opacity: MotionValue<number>;  className?: string;}) {  const scale = useTransform(opacity, [0, 1], [0.85, 1]);  return (    <motion.span      aria-hidden      style={{ opacity, scale }}      className={cn(        "absolute top-3 inline-flex h-7 items-center rounded-full px-3",        "text-[11px] font-semibold tracking-[0.08em] uppercase",        "backdrop-blur-md shadow-[0_4px_12px_rgba(0,0,0,0.12)]",        tone === "save" ? "bg-emerald-500/90 text-white" : "bg-black/70 text-white",        className,      )}    >      {label}    </motion.span>  );}
/* ── Controls ────────────────────────────────────────────── */
function ActionButton({  label,  tone,  topX,  side,  disabled,  onClick,  children,}: {  label: string;  tone: "save" | "skip";  topX: MotionValue<number>;  side: 1 | -1;  disabled: boolean;  onClick: () => void;  children: React.ReactNode;}) {  // The matching button swells as a drag heads its way.  const lean = useTransform(topX, (v) => clamp01((v * side) / DISMISS_DISTANCE));  const scale = useTransform(lean, [0, 1], [1, 1.12]);  const fill = useTransform(lean, [0, 1], [0, 1]);
  return (    <motion.button      type="button"      onClick={onClick}      disabled={disabled}      aria-label={label}      style={{ scale }}      whileTap={{ scale: 0.92 }}      className={cn(        "relative grid size-14 place-items-center overflow-hidden rounded-full",        "border border-(--color-rule) bg-(--color-paper)",        "shadow-[0_1px_2px_rgba(0,0,0,0.04),0_6px_16px_rgba(0,0,0,0.06)]",        "outline-none focus-visible:ring-2 focus-visible:ring-(--color-focus) disabled:opacity-35",        tone === "save" ? "text-emerald-600 dark:text-emerald-400" : "text-(--color-ink-2)",      )}    >      <motion.span        aria-hidden        style={{ opacity: fill }}        className={cn(          "absolute inset-0",          tone === "save" ? "bg-emerald-500/12" : "bg-(--color-ink)/6",        )}      />      <span className="relative">{children}</span>    </motion.button>  );}
/* ── End state ───────────────────────────────────────────── */
function DeckSummary({  saved,  total,  rightLabel,  onRestart,}: {  saved: SwipeDeckCard[];  total: number;  rightLabel: string;  onRestart: () => void;}) {  return (    <motion.div      initial={{ opacity: 0, scale: 0.97 }}      animate={{ opacity: 1, scale: 1 }}      transition={{ duration: 0.25, ease: [0.23, 1, 0.32, 1] }}      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"    >      {saved.length > 0 ? (        <div className="flex -space-x-3">          {saved.slice(0, 4).map((card, i) => (            <span              key={card.id}              className="size-11 overflow-hidden rounded-full bg-(--color-paper-2) ring-2 ring-(--color-paper)"              style={{ zIndex: 4 - i }}            >              {card.image ? (                // eslint-disable-next-line @next/next/no-img-element                <img src={card.image} alt="" className="h-full w-full object-cover object-top" />              ) : (                <span className="block h-full w-full" style={{ background: card.accent }} />              )}            </span>          ))}        </div>      ) : null}
      <div>        <div className="text-[15px] font-medium text-(--color-ink)">All caught up</div>        <div className="mt-1 text-[13px] text-(--color-neutral)">          {rightLabel}: {saved.length} of {total}        </div>      </div>
      <button        type="button"        onClick={onRestart}        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]"      >        Start over      </button>    </motion.div>  );}

Adjust import paths according to your folder structure.


Basic Usage

tsx
import { SwipeDeck } from "@/components/ui/swipe-deck";
<SwipeDeck  cards={[    { id: "1", title: "Signal", description: "Flick to dismiss." },    { id: "2", title: "Momentum", description: "Velocity wins." },  ]}/>

Props

PropTypeRequiredDefaultDescription
cardsSwipeDeckCard[]Yes—Deck cards — id, title, optional description, image, and accent.
leftLabelstringNo"Skip"Label for a left swipe, shown on the stamp and button.
rightLabelstringNo"Save"Label for a right swipe, shown on the stamp and button.
onSwipe(id: string, direction: "left" | "right") => voidNo—Fires with the direction each time a card leaves the deck.
onDismiss(id: string) => voidNo—Fires when a card leaves the deck, in either direction.
onUndo(id: string) => voidNo—Fires when Undo returns a card to the deck.
onEmpty() => voidNo—Fires when the last card leaves the deck.
classNamestringNo—Optional class names on the deck wrapper.

Best Practices

  1. Project the resting point from velocity — never snap only from release position.
  2. Let a fast flick win even below the distance threshold.
  3. Hand velocity into the spring so drag and fling share one seam.
  4. Let the stack respond to the drag so the hand-off to the next card has no jump.
  5. Give every gesture a button and a key: swipe, Skip/Save buttons, arrows, Backspace to undo.
  6. Under prefers-reduced-motion, disable drag; the buttons and keys still work, without the flight.