Overview

A card deck that stays glued to the finger, projects the release with exponential decay, and rubber-bands past the edge. Springs are critically damped on settle; a light bounce only rides the flick. Interruptible — grab mid-flight and it retargets from the live value.


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, useRef, useState } from "react";import {  AnimatePresence,  motion,  useMotionValue,  useTransform,  animate,  useReducedMotion,} from "motion/react";import { cn } from "@/lib/utils";
export type SwipeDeckCard = {  id: string;  title: string;  description?: string;  image?: string;  accent?: string;};
export type SwipeDeckProps = {  cards: SwipeDeckCard[];  className?: string;  onDismiss?: (id: string) => void;  onEmpty?: () => void;};
const DECEL = 0.998;const DISMISS_DISTANCE = 120;const DISMISS_VELOCITY = 550;
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))  );}
function DeckCard({  card,  index,  total,  active,  reduce,  onDismiss,}: {  card: SwipeDeckCard;  index: number;  total: number;  active: boolean;  reduce: boolean | null;  onDismiss: () => void;}) {  const x = useMotionValue(0);  const y = useMotionValue(0);  const rotate = useTransform(x, [-260, 0, 260], [-14, 0, 14]);  const opacity = useTransform(x, [-320, -100, 0, 100, 320], [0, 1, 1, 1, 0]);
  const depth = total - 1 - index;  const stackScale = 1 - depth * 0.05;  const stackY = depth * 14;
  const dragging = useRef(false);  const start = useRef({ x: 0, y: 0 });  const samples = useRef<{ t: number; x: number; y: number }[]>([]);
  const settleHome = useCallback(    (vx: number, vy: number) => {      animate(x, 0, {        type: "spring",        bounce: 0,        duration: 0.4,        velocity: vx,      });      animate(y, 0, {        type: "spring",        bounce: 0,        duration: 0.4,        velocity: vy,      });    },    [x, y],  );
  const flingOut = useCallback(    (dir: 1 | -1, vx: number, vy: number) => {      const target =        dir * (typeof window !== "undefined" ? window.innerWidth * 0.95 : 480);      Promise.all([        animate(x, target, {          type: "spring",          bounce: 0.12, // flick-driven only          duration: 0.38,          velocity: vx,        }),        animate(y, vy * 0.04, {          type: "spring",          bounce: 0,          duration: 0.38,          velocity: vy,        }),      ]).then(() => onDismiss());    },    [onDismiss, x, y],  );
  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) => {    if (!active || reduce) return;    dragging.current = true;    start.current = { x: e.clientX, y: e.clientY };    samples.current = [{ t: performance.now(), x: e.clientX, y: e.clientY }];    (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);  };
  const onPointerMove = (e: React.PointerEvent) => {    if (!dragging.current || !active) return;    let nextX = e.clientX - start.current.x;    const nextY = (e.clientY - start.current.y) * 0.28;    const max = 260;    if (Math.abs(nextX) > max) {      const sign = Math.sign(nextX);      nextX = sign * (max + rubberband(Math.abs(nextX) - max, max));    }    x.set(nextX);    y.set(nextY);    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 || !active) return;    dragging.current = false;    const { vx, vy } = velocity();    const projected = x.get() + project(vx);    if (      Math.abs(projected) > DISMISS_DISTANCE ||      Math.abs(vx) > DISMISS_VELOCITY    ) {      flingOut(projected >= 0 ? 1 : -1, vx, vy);    } else {      settleHome(vx, vy);    }  };
  return (    <motion.article      onPointerDown={onPointerDown}      onPointerMove={onPointerMove}      onPointerUp={onPointerUp}      onPointerCancel={onPointerUp}      style={{        x: active ? x : 0,        y: active ? y : stackY,        rotate: active ? rotate : 0,        opacity: active ? opacity : 1,        scale: stackScale,        zIndex: index + 1,        willChange: active ? "transform" : undefined,      }}      initial={false}      className={cn(        "absolute inset-x-0 top-0 mx-auto w-full max-w-[min(100%,380px)] touch-none select-none",        active ? "cursor-grab active:cursor-grabbing" : "pointer-events-none",        "rounded-lg border border-(--color-rule) bg-(--color-paper)",        "shadow-[0_1px_2px_rgba(0,0,0,0.04),0_8px_24px_rgba(0,0,0,0.06),0_24px_48px_rgba(0,0,0,0.06)]",        "dark:shadow-[0_1px_2px_rgba(0,0,0,0.2),0_12px_32px_rgba(0,0,0,0.35),0_28px_56px_rgba(0,0,0,0.25)]",        "outline outline-1 outline-black/6 dark:outline-white/10",      )}    >      <div className="relative aspect-16/10 overflow-hidden rounded-t-[calc(var(--radius-lg)-1px)] 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%)",            }}          />        )}      </div>
      <div className="flex flex-col gap-2 px-5 pt-4 pb-5">        {active ? (          <div            className="mx-auto mb-1 h-1 w-9 rounded-full bg-(--color-rule)"            aria-hidden          />        ) : null}
        <h3 className="truncate font-(family-name:--font-display) text-[17px] font-medium tracking-[-0.02em] text-(--color-ink) sm:text-[18px]">          {card.title}        </h3>
        {card.description ? (          <p className="line-clamp-2 text-[13px] leading-relaxed text-pretty text-(--color-ink-2) sm:text-[14px]">            {card.description}          </p>        ) : null}
        {active && reduce ? (          <button            type="button"            onClick={onDismiss}            className="mt-2 min-h-10 w-full rounded-sm border border-(--color-rule) bg-(--color-paper-2) text-[13px] font-medium text-(--color-ink) transition-transform duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] active:scale-[0.96]"          >            Dismiss          </button>        ) : null}      </div>    </motion.article>  );}
export function SwipeDeck({  cards: initial,  className,  onDismiss,  onEmpty,}: SwipeDeckProps) {  const reduce = useReducedMotion();  const [cards, setCards] = useState(initial);  const seed = useRef(initial);
  const dismiss = (id: string) => {    onDismiss?.(id);    setCards((prev) => {      const next = prev.filter((c) => c.id !== id);      if (next.length === 0) onEmpty?.();      return next;    });  };
  const reset = () => setCards(seed.current);
  return (    <div      className={cn(        "relative mx-auto w-full max-w-[380px]",        className,      )}    >      <div className="relative h-[320px] w-full sm:h-[360px]">        {cards.length === 0 ? (          <div className="flex h-full flex-col items-center justify-center gap-4 rounded-lg border border-dashed border-(--color-rule) bg-(--color-paper-2)/40">            <p className="text-[13px] text-(--color-ink-2)">Deck cleared</p>            <button              type="button"              onClick={reset}              className="min-h-10 rounded-sm border border-(--color-rule) bg-(--color-paper) px-5 text-[13px] font-medium text-(--color-ink) transition-transform duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] active:scale-[0.96]"            >              Reset            </button>          </div>        ) : (          <AnimatePresence initial={false}>            {cards.map((card, i) => (              <DeckCard                key={card.id}                card={card}                index={i}                total={cards.length}                active={i === cards.length - 1}                reduce={reduce}                onDismiss={() => dismiss(card.id)}              />            ))}          </AnimatePresence>        )}      </div>    </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[]YesDeck cards — id, title, optional description, image, and accent.
onDismiss(id: string) => voidNoFires when a card is flicked away.
onEmpty() => voidNoFires when the last card leaves the deck.
classNamestringNoOptional 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. Under prefers-reduced-motion, disable drag and expose a dismiss control.