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.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, useRef, useState } from "react";4import {5 AnimatePresence,6 motion,7 useMotionValue,8 useTransform,9 animate,10 useReducedMotion,11} from "motion/react";12import { cn } from "@/lib/utils";13 14export type SwipeDeckCard = {15 id: string;16 title: string;17 description?: string;18 image?: string;19 accent?: string;20};21 22export type SwipeDeckProps = {23 cards: SwipeDeckCard[];24 className?: string;25 onDismiss?: (id: string) => void;26 onEmpty?: () => void;27};28 29const DECEL = 0.998;30const DISMISS_DISTANCE = 120;31const DISMISS_VELOCITY = 550;32 33function project(velocity: number, decelerationRate = DECEL) {34 return ((velocity / 1000) * decelerationRate) / (1 - decelerationRate);35}36 37function rubberband(overshoot: number, dimension: number, constant = 0.55) {38 return (39 (overshoot * dimension * constant) /40 (dimension + constant * Math.abs(overshoot))41 );42}43 44function DeckCard({45 card,46 index,47 total,48 active,49 reduce,50 onDismiss,51}: {52 card: SwipeDeckCard;53 index: number;54 total: number;55 active: boolean;56 reduce: boolean | null;57 onDismiss: () => void;58}) {59 const x = useMotionValue(0);60 const y = useMotionValue(0);61 const rotate = useTransform(x, [-260, 0, 260], [-14, 0, 14]);62 const opacity = useTransform(x, [-320, -100, 0, 100, 320], [0, 1, 1, 1, 0]);63 64 const depth = total - 1 - index;65 const stackScale = 1 - depth * 0.05;66 const stackY = depth * 14;67 68 const dragging = useRef(false);69 const start = useRef({ x: 0, y: 0 });70 const samples = useRef<{ t: number; x: number; y: number }[]>([]);71 72 const settleHome = useCallback(73 (vx: number, vy: number) => {74 animate(x, 0, {75 type: "spring",76 bounce: 0,77 duration: 0.4,78 velocity: vx,79 });80 animate(y, 0, {81 type: "spring",82 bounce: 0,83 duration: 0.4,84 velocity: vy,85 });86 },87 [x, y],88 );89 90 const flingOut = useCallback(91 (dir: 1 | -1, vx: number, vy: number) => {92 const target =93 dir * (typeof window !== "undefined" ? window.innerWidth * 0.95 : 480);94 Promise.all([95 animate(x, target, {96 type: "spring",97 bounce: 0.12, // flick-driven only98 duration: 0.38,99 velocity: vx,100 }),101 animate(y, vy * 0.04, {102 type: "spring",103 bounce: 0,104 duration: 0.38,105 velocity: vy,106 }),107 ]).then(() => onDismiss());108 },109 [onDismiss, x, y],110 );111 112 const velocity = () => {113 const s = samples.current;114 if (s.length < 2) return { vx: 0, vy: 0 };115 const a = s[0];116 const b = s[s.length - 1];117 const dt = (b.t - a.t) / 1000;118 if (dt <= 0) return { vx: 0, vy: 0 };119 return { vx: (b.x - a.x) / dt, vy: (b.y - a.y) / dt };120 };121 122 const onPointerDown = (e: React.PointerEvent) => {123 if (!active || reduce) return;124 dragging.current = true;125 start.current = { x: e.clientX, y: e.clientY };126 samples.current = [{ t: performance.now(), x: e.clientX, y: e.clientY }];127 (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);128 };129 130 const onPointerMove = (e: React.PointerEvent) => {131 if (!dragging.current || !active) return;132 let nextX = e.clientX - start.current.x;133 const nextY = (e.clientY - start.current.y) * 0.28;134 const max = 260;135 if (Math.abs(nextX) > max) {136 const sign = Math.sign(nextX);137 nextX = sign * (max + rubberband(Math.abs(nextX) - max, max));138 }139 x.set(nextX);140 y.set(nextY);141 samples.current.push({142 t: performance.now(),143 x: e.clientX,144 y: e.clientY,145 });146 if (samples.current.length > 5) samples.current.shift();147 };148 149 const onPointerUp = () => {150 if (!dragging.current || !active) return;151 dragging.current = false;152 const { vx, vy } = velocity();153 const projected = x.get() + project(vx);154 if (155 Math.abs(projected) > DISMISS_DISTANCE ||156 Math.abs(vx) > DISMISS_VELOCITY157 ) {158 flingOut(projected >= 0 ? 1 : -1, vx, vy);159 } else {160 settleHome(vx, vy);161 }162 };163 164 return (165 <motion.article166 onPointerDown={onPointerDown}167 onPointerMove={onPointerMove}168 onPointerUp={onPointerUp}169 onPointerCancel={onPointerUp}170 style={{171 x: active ? x : 0,172 y: active ? y : stackY,173 rotate: active ? rotate : 0,174 opacity: active ? opacity : 1,175 scale: stackScale,176 zIndex: index + 1,177 willChange: active ? "transform" : undefined,178 }}179 initial={false}180 className={cn(181 "absolute inset-x-0 top-0 mx-auto w-full max-w-[min(100%,380px)] touch-none select-none",182 active ? "cursor-grab active:cursor-grabbing" : "pointer-events-none",183 "rounded-lg border border-(--color-rule) bg-(--color-paper)",184 "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)]",185 "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)]",186 "outline outline-1 outline-black/6 dark:outline-white/10",187 )}188 >189 <div className="relative aspect-16/10 overflow-hidden rounded-t-[calc(var(--radius-lg)-1px)] bg-(--color-paper-2)">190 {card.image ? (191 // eslint-disable-next-line @next/next/no-img-element192 <img193 src={card.image}194 alt=""195 draggable={false}196 className="h-full w-full object-cover object-top"197 />198 ) : (199 <div200 className="h-full w-full"201 style={{202 background:203 card.accent ??204 "linear-gradient(145deg, var(--color-paper-2) 0%, var(--color-rule) 100%)",205 }}206 />207 )}208 </div>209 210 <div className="flex flex-col gap-2 px-5 pt-4 pb-5">211 {active ? (212 <div213 className="mx-auto mb-1 h-1 w-9 rounded-full bg-(--color-rule)"214 aria-hidden215 />216 ) : null}217 218 <h3 className="truncate font-(family-name:--font-display) text-[17px] font-medium tracking-[-0.02em] text-(--color-ink) sm:text-[18px]">219 {card.title}220 </h3>221 222 {card.description ? (223 <p className="line-clamp-2 text-[13px] leading-relaxed text-pretty text-(--color-ink-2) sm:text-[14px]">224 {card.description}225 </p>226 ) : null}227 228 {active && reduce ? (229 <button230 type="button"231 onClick={onDismiss}232 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]"233 >234 Dismiss235 </button>236 ) : null}237 </div>238 </motion.article>239 );240}241 242export function SwipeDeck({243 cards: initial,244 className,245 onDismiss,246 onEmpty,247}: SwipeDeckProps) {248 const reduce = useReducedMotion();249 const [cards, setCards] = useState(initial);250 const seed = useRef(initial);251 252 const dismiss = (id: string) => {253 onDismiss?.(id);254 setCards((prev) => {255 const next = prev.filter((c) => c.id !== id);256 if (next.length === 0) onEmpty?.();257 return next;258 });259 };260 261 const reset = () => setCards(seed.current);262 263 return (264 <div265 className={cn(266 "relative mx-auto w-full max-w-[380px]",267 className,268 )}269 >270 <div className="relative h-[320px] w-full sm:h-[360px]">271 {cards.length === 0 ? (272 <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">273 <p className="text-[13px] text-(--color-ink-2)">Deck cleared</p>274 <button275 type="button"276 onClick={reset}277 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]"278 >279 Reset280 </button>281 </div>282 ) : (283 <AnimatePresence initial={false}>284 {cards.map((card, i) => (285 <DeckCard286 key={card.id}287 card={card}288 index={i}289 total={cards.length}290 active={i === cards.length - 1}291 reduce={reduce}292 onDismiss={() => dismiss(card.id)}293 />294 ))}295 </AnimatePresence>296 )}297 </div>298 </div>299 );300}
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. |
| onDismiss | (id: string) => void | No | — | Fires when a card is flicked away. |
| 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.
- Under
prefers-reduced-motion, disable drag and expose a dismiss control.