Mobile & Consumer

Swipe Card Deck

A draggable card stack for triage with flick-velocity commits, rubber-band snap-back, and arrow-key fallback.

Install

npx shadcn@latest add @paragon/swipe-card-deck

swipe-card-deck.tsx

"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>
  );
}