Swipe Card Stack
A Tinder-style deck on hand-written springs: the top card tilts with your drag, LIKE and NOPE stamps fade in with a wash of colour, the cards behind rise as you commit, and a flick throws it with your velocity. Buttons, arrow keys and undo, plus a finished end state.
DOM + CSS0 deps1 file · 26 KB TSXreduced-motion aware
Props
"use client";
/*
* SwipeCardStack: a deck you throw cards off, on hand-written springs.
*
* Drag the top card and it tilts with its own x (a little more when you hold it
* high), a LIKE or NOPE stamp fades in with a wash of colour, and the cards
* underneath rise toward the front as you commit. Let go past the threshold, or
* flick it hard enough that it would clear the threshold, and it is thrown off
* the stack carrying your velocity. Buttons, the arrow keys and Backspace do the
* same jobs without a pointer. Undo flies the last card back; when the deck runs
* out a finished end state offers to start over.
*/
import {
useEffect,
useRef,
useState,
useSyncExternalStore,
type KeyboardEvent as ReactKeyboardEvent,
type PointerEvent as ReactPointerEvent,
type ReactNode,
type RefObject,
} from "react";
export type SwipeCard = {
id: string | number;
title: string;
subtitle?: string;
meta?: string;
tag?: string;
/** Hue (0-360) of the generated gradient artwork. */
hue?: number;
};
export type SwipeDirection = "left" | "right";
export type SwipeCardStackProps = {
/** Distance in px a card must travel before letting go throws it. */
threshold?: number;
/** How many cards are rendered at once. The rest wait behind them. */
stackDepth?: number;
cards?: SwipeCard[];
/** Deal the deck again forever (default). Set false for a finite deck with an end state. */
loop?: boolean;
likeLabel?: string;
nopeLabel?: string;
onSwipe?: (card: SwipeCard, direction: SwipeDirection) => void;
onUndo?: (card: SwipeCard) => void;
/** Called once the last card has been thrown (never when `loop` is on). */
onEnd?: () => void;
/** Extra classes for the root. The default width is w-64. */
className?: string;
};
const DEMO_CARDS: SwipeCard[] = [
{ id: 1, title: "Alder Cove", subtitle: "Pacific Northwest", meta: "3 nights from $420", tag: "Coast", hue: 205 },
{ id: 2, title: "Kestrel Ridge", subtitle: "Dolomites, Italy", meta: "5 nights from $690", tag: "Alpine", hue: 268 },
{ id: 3, title: "Saffron Quarter", subtitle: "Marrakesh", meta: "4 nights from $540", tag: "City", hue: 24 },
{ id: 4, title: "Lumen Atoll", subtitle: "Maldives", meta: "6 nights from $1,180", tag: "Island", hue: 168 },
{ id: 5, title: "Tidewater House", subtitle: "Cornwall", meta: "2 nights from $310", tag: "Retreat", hue: 332 },
{ id: 6, title: "Obsidian Flats", subtitle: "Iceland", meta: "7 nights from $960", tag: "Wild", hue: 152 },
];
const LIKE = "#34d399";
const NOPE = "#fb7185";
const GRAIN =
"url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='140' height='140'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2' stitchTiles='stitch'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>\")";
/* ---- Engine: one rAF loop that springs every mounted card ---------------- */
type Dir = 1 | -1;
interface Rec {
el: HTMLElement;
like: HTMLElement | null;
nope: HTMLElement | null;
likeWash: HTMLElement | null;
nopeWash: HTMLElement | null;
veil: HTMLElement | null;
x: number;
y: number;
vx: number;
vy: number;
/** Effective depth in the stack: 0 is the front. Springs toward the real depth. */
d: number;
vd: number;
/** Resting y of a thrown card (a little lift). */
lift: number;
out: 0 | Dir;
drag: boolean;
hidden: boolean;
/** Where on the card (0 top, 1 bottom) it was grabbed. */
gy: number;
px: number;
py: number;
x0: number;
y0: number;
sc: number;
pid: number;
samples: { t: number; x: number }[];
}
interface Eng {
recs: Map<number, Rec>;
/** Direction each card was last thrown, kept so an undo can fly it back in. */
dirs: Map<number, Dir>;
log: Dir[];
idx: number;
n: number;
loop: boolean;
thr: number;
depth: number;
W: number;
hint: -1 | 0 | 1;
reduce: boolean;
ready: boolean;
deck: HTMLElement | null;
raf: number;
last: number;
wake: (() => void) | null;
}
const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v));
const FAR_K = 2.2;
function makeEng(n: number): Eng {
return {
recs: new Map(),
dirs: new Map(),
log: [],
idx: 0,
n,
loop: false,
thr: 120,
depth: 3,
W: 256,
hint: 0,
reduce: false,
ready: false,
deck: null,
raf: 0,
last: 0,
wake: null,
};
}
/** Advance every card by dt seconds and write the result to the DOM. Returns true while anything is still moving. */
function step(e: Eng, dt: number): boolean {
const W = e.W;
const FAR = W * FAR_K;
const thr = e.thr;
let active = false;
const top = e.recs.get(e.idx);
const p = top ? clamp(Math.abs(top.x) / thr, 0, 1) : 0;
const sub = dt > 0 ? 2 : 0;
const h = dt / 2;
e.recs.forEach((r, i) => {
const depth = i - e.idx;
if (r.hidden && depth < 0) return;
if (r.hidden) {
r.hidden = false;
r.el.style.visibility = "visible";
}
let tx = 0;
let ty = 0;
let td = 0;
let k = 170;
let c = 22;
if (depth < 0) {
tx = r.out * FAR;
ty = r.lift;
k = 48;
c = 11.5;
} else {
if (depth === 0 && !r.drag) tx = e.hint * 14;
td = depth === 0 ? 0 : Math.max(0, depth - p);
}
if (e.reduce && !r.drag) {
r.x = depth < 0 ? r.out * W * 0.2 : tx;
r.y = depth < 0 ? 0 : ty;
r.d = td;
r.vx = r.vy = r.vd = 0;
} else {
for (let s = 0; s < sub; s++) {
if (!r.drag) {
r.vx += (-k * (r.x - tx) - c * r.vx) * h;
r.x += r.vx * h;
r.vy += (-k * (r.y - ty) - c * r.vy) * h;
r.y += r.vy * h;
}
r.vd += (-170 * (r.d - td) - 24 * r.vd) * h;
r.d += r.vd * h;
}
const rest =
!r.drag &&
Math.abs(r.x - tx) < 0.15 &&
Math.abs(r.vx) < 3 &&
Math.abs(r.y - ty) < 0.15 &&
Math.abs(r.vy) < 3 &&
Math.abs(r.d - td) < 0.002 &&
Math.abs(r.vd) < 0.03;
if (rest) {
r.x = tx;
r.y = ty;
r.d = td;
r.vx = r.vy = r.vd = 0;
} else active = true;
}
const rot = clamp((r.x / W) * 18 * (1 + (0.5 - r.gy) * 0.9), -34, 34);
const sc = 1 - 0.06 * r.d;
const st = r.el.style;
st.transform =
"translate3d(" + r.x.toFixed(2) + "px," + (r.y - 26 * r.d).toFixed(2) + "px,0) rotate(" + rot.toFixed(3) + "deg) scale(" + sc.toFixed(4) + ")";
const gone = depth < 0 && e.reduce;
st.transition = e.reduce ? "opacity 200ms linear" : "none";
st.opacity = gone ? "0" : String(clamp(e.depth - r.d, 0, 1));
if (r.veil) r.veil.style.opacity = String(clamp(r.d * 0.16, 0, 0.7));
const lv = depth <= 0 && !gone ? clamp((r.x - 0.12 * thr) / (0.7 * thr), 0, 1) : 0;
const nv = depth <= 0 && !gone ? clamp((-r.x - 0.12 * thr) / (0.7 * thr), 0, 1) : 0;
if (r.like) {
r.like.style.opacity = String(lv);
r.like.style.transform = "rotate(-12deg) scale(" + (0.82 + 0.18 * lv).toFixed(3) + ")";
}
if (r.nope) {
r.nope.style.opacity = String(nv);
r.nope.style.transform = "rotate(12deg) scale(" + (0.82 + 0.18 * nv).toFixed(3) + ")";
}
if (r.likeWash) r.likeWash.style.opacity = String(lv * 0.85);
if (r.nopeWash) r.nopeWash.style.opacity = String(nv * 0.85);
if (depth < 0 && Math.abs(r.x) > W * 1.5 && !r.drag) {
r.hidden = true;
st.visibility = "hidden";
}
});
return active;
}
function register(e: Eng, i: number, el: HTMLElement): Rec {
const q = (s: string) => el.querySelector<HTMLElement>('[data-ssc="' + s + '"]');
const depth = i - e.idx;
const rec: Rec = {
el,
like: q("like"),
nope: q("nope"),
likeWash: q("like-wash"),
nopeWash: q("nope-wash"),
veil: q("veil"),
x: 0,
y: 0,
vx: 0,
vy: 0,
d: e.ready && depth > 0 ? depth + 1 : Math.max(0, depth),
vd: 0,
lift: -26,
out: 0,
drag: false,
hidden: false,
gy: 0.5,
px: 0,
py: 0,
x0: 0,
y0: 0,
sc: 1,
pid: -1,
samples: [],
};
const dir = e.dirs.get(i);
if (depth < 0) {
rec.out = dir ?? 1;
rec.x = rec.out * e.W * FAR_K;
rec.y = rec.lift;
} else if (depth === 0 && e.ready && dir) {
rec.x = dir * e.W * FAR_K;
rec.y = -26;
}
e.recs.set(i, rec);
step(e, 0);
e.wake?.();
return rec;
}
/* ---- Hooks ---------------------------------------------------------------- */
function subscribeMotion(cb: () => void) {
const m = window.matchMedia("(prefers-reduced-motion: reduce)");
m.addEventListener("change", cb);
return () => m.removeEventListener("change", cb);
}
/** True when the visitor asked for reduced motion. False on the server. */
function useReducedMotion(): boolean {
return useSyncExternalStore(
subscribeMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
/* ---- Artwork --------------------------------------------------------------- */
function artFor(hue: number): string {
const h = ((Math.round(hue) % 360) + 360) % 360;
return [
"radial-gradient(120% 78% at 18% -4%, hsl(" + h + " 92% 64% / 0.95), transparent 58%)",
"radial-gradient(90% 70% at 96% 34%, hsl(" + ((h + 52) % 360) + " 88% 58% / 0.8), transparent 62%)",
"radial-gradient(110% 80% at 40% 104%, hsl(" + ((h + 330) % 360) + " 80% 30%), transparent 72%)",
"linear-gradient(162deg, hsl(" + h + " 58% 15%), hsl(" + ((h + 28) % 360) + " 52% 7%))",
].join(",");
}
/* ---- The card ----------------------------------------------------------- */
function DeckCard({
i,
card,
engRef,
index,
position,
total,
likeLabel,
nopeLabel,
onToss,
}: {
i: number;
card: SwipeCard;
engRef: RefObject<Eng | null>;
index: number;
position: number;
total: number;
likeLabel: string;
nopeLabel: string;
onToss: (i: number, dir: Dir, v: number) => void;
}) {
const elRef = useRef<HTMLDivElement>(null);
const isTop = i === index;
useEffect(() => {
const e = engRef.current;
const el = elRef.current;
if (!e || !el) return;
register(e, i, el);
return () => {
e.recs.delete(i);
};
}, [i, engRef]);
function down(ev: ReactPointerEvent<HTMLDivElement>) {
const e = engRef.current;
if (!e || i !== e.idx) return;
if (ev.pointerType === "mouse" && ev.button !== 0) return;
const rec = e.recs.get(i);
if (!rec) return;
const deck = e.deck;
const rect = rec.el.getBoundingClientRect();
rec.sc = deck ? deck.offsetWidth / Math.max(1, deck.getBoundingClientRect().width) : 1;
rec.drag = true;
rec.px = ev.clientX;
rec.py = ev.clientY;
rec.x0 = rec.x;
rec.y0 = rec.y;
rec.vx = rec.vy = 0;
rec.gy = clamp((ev.clientY - rect.top) / Math.max(1, rect.height), 0.2, 0.8);
rec.pid = ev.pointerId;
rec.samples = [{ t: ev.timeStamp, x: rec.x }];
try {
rec.el.setPointerCapture(ev.pointerId);
} catch {
/* capture is a nicety */
}
e.wake?.();
}
function move(ev: ReactPointerEvent<HTMLDivElement>) {
const e = engRef.current;
const rec = e?.recs.get(i);
if (!e || !rec || !rec.drag || ev.pointerId !== rec.pid) return;
rec.x = rec.x0 + (ev.clientX - rec.px) * rec.sc;
rec.y = clamp(rec.y0 + (ev.clientY - rec.py) * rec.sc * 0.35, -70, 70);
rec.samples.push({ t: ev.timeStamp, x: rec.x });
while (rec.samples.length > 2 && ev.timeStamp - (rec.samples[0]?.t ?? 0) > 90) rec.samples.shift();
e.wake?.();
}
function end(ev: ReactPointerEvent<HTMLDivElement>, cancelled: boolean) {
const e = engRef.current;
const rec = e?.recs.get(i);
if (!e || !rec || !rec.drag || ev.pointerId !== rec.pid) return;
rec.drag = false;
try {
rec.el.releasePointerCapture(ev.pointerId);
} catch {
/* already released */
}
const a = rec.samples[0];
const b = rec.samples[rec.samples.length - 1];
let v = 0;
if (a && b && b.t > a.t) v = clamp(((b.x - a.x) / (b.t - a.t)) * 1000, -3200, 3200);
const proj = rec.x + v * 0.16;
if (!cancelled && Math.abs(proj) > e.thr) onToss(i, proj > 0 ? 1 : -1, v);
else rec.vx = clamp(v, -1400, 1400);
e.wake?.();
}
const hue = Number.isFinite(card.hue) ? (card.hue as number) : (i * 57 + 200) % 360;
const label = [card.title, card.subtitle, card.meta].filter(Boolean).join(", ");
return (
<div
ref={elRef}
role={isTop ? "group" : undefined}
aria-roledescription={isTop ? "card" : undefined}
aria-label={isTop ? "Card " + position + " of " + total + ": " + label : undefined}
aria-hidden={isTop ? undefined : true}
onPointerDown={down}
onPointerMove={move}
onPointerUp={(ev) => end(ev, false)}
onPointerCancel={(ev) => end(ev, true)}
className="absolute inset-0 select-none overflow-hidden rounded-[28px] will-change-transform"
style={{
zIndex: i < index ? 60 + i : 40 - (i - index),
touchAction: "pan-y",
cursor: isTop ? "grab" : "default",
background: "#0b0c0f",
boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.1), 0 28px 50px -18px rgba(0,0,0,0.8), 0 6px 14px -6px rgba(0,0,0,0.6)",
}}
>
<div aria-hidden className="absolute inset-0" style={{ background: artFor(hue) }} />
<div
aria-hidden
className="absolute -right-10 top-8 h-44 w-44 rounded-full"
style={{ border: "1px solid rgba(255,255,255,0.22)", boxShadow: "0 0 0 22px rgba(255,255,255,0.04), 0 0 0 46px rgba(255,255,255,0.025)" }}
/>
<div aria-hidden className="absolute inset-0 opacity-[0.07] mix-blend-overlay" style={{ backgroundImage: GRAIN }} />
<div
aria-hidden
className="absolute inset-x-0 bottom-0 h-[62%]"
style={{ background: "linear-gradient(to top, rgba(5,6,8,0.94) 8%, rgba(5,6,8,0.55) 55%, rgba(5,6,8,0))" }}
/>
<div className="absolute inset-x-0 bottom-0 flex flex-col gap-1 p-5">
{card.tag ? (
<span
className="mb-1.5 w-fit rounded-full px-2.5 py-1 text-[10px] font-medium uppercase tracking-[0.16em] text-white/85 backdrop-blur-md"
style={{ background: "rgba(255,255,255,0.12)", border: "1px solid rgba(255,255,255,0.16)" }}
>
{card.tag}
</span>
) : null}
<span className="text-[22px] font-semibold leading-[1.1] tracking-tight text-white">{card.title}</span>
{card.subtitle ? <span className="text-[13px] text-white/65">{card.subtitle}</span> : null}
{card.meta ? (
<span className="mt-2.5 pt-2.5 text-[11px] uppercase tracking-[0.14em] text-white/45" style={{ borderTop: "1px solid rgba(255,255,255,0.12)" }}>
{card.meta}
</span>
) : null}
</div>
<div
aria-hidden
data-ssc="like-wash"
className="pointer-events-none absolute inset-0 opacity-0"
style={{ background: "linear-gradient(100deg, rgba(52,211,153,0.5), rgba(52,211,153,0) 62%)" }}
/>
<div
aria-hidden
data-ssc="nope-wash"
className="pointer-events-none absolute inset-0 opacity-0"
style={{ background: "linear-gradient(260deg, rgba(251,113,133,0.5), rgba(251,113,133,0) 62%)" }}
/>
<div aria-hidden data-ssc="veil" className="pointer-events-none absolute inset-0 opacity-0" style={{ background: "#050608" }} />
<span
aria-hidden
data-ssc="like"
className="pointer-events-none absolute left-5 top-6 rounded-lg px-2.5 py-0.5 text-[22px] font-extrabold uppercase tracking-[0.1em] opacity-0"
style={{ color: LIKE, border: "3px solid " + LIKE, textShadow: "0 0 18px rgba(52,211,153,0.6)", boxShadow: "0 0 24px rgba(52,211,153,0.25)" }}
>
{likeLabel}
</span>
<span
aria-hidden
data-ssc="nope"
className="pointer-events-none absolute right-5 top-6 rounded-lg px-2.5 py-0.5 text-[22px] font-extrabold uppercase tracking-[0.1em] opacity-0"
style={{ color: NOPE, border: "3px solid " + NOPE, textShadow: "0 0 18px rgba(251,113,133,0.6)", boxShadow: "0 0 24px rgba(251,113,133,0.25)" }}
>
{nopeLabel}
</span>
</div>
);
}
/* ---- Round buttons -------------------------------------------------------- */
function RoundButton({
label,
color,
size,
disabled,
onClick,
onHint,
children,
}: {
label: string;
color: string;
size: number;
disabled: boolean;
onClick: () => void;
onHint?: (on: boolean) => void;
children: ReactNode;
}) {
return (
<button
type="button"
aria-label={label}
disabled={disabled}
onClick={onClick}
onPointerEnter={(e) => e.pointerType === "mouse" && onHint?.(true)}
onPointerLeave={() => onHint?.(false)}
onFocus={(e) => e.currentTarget.matches(":focus-visible") && onHint?.(true)}
onBlur={() => onHint?.(false)}
className="grid shrink-0 place-items-center rounded-full bg-[#14161b] transition-[transform,background-color,box-shadow,opacity] duration-150 hover:bg-[#1e2128] active:scale-90 disabled:pointer-events-none disabled:opacity-30 motion-reduce:transition-none focus-visible:!rounded-full focus-visible:!outline-2 focus-visible:!outline-offset-2 focus-visible:!outline-[#7c8cff]"
style={{
width: size,
height: size,
color,
border: "1px solid rgba(255,255,255,0.1)",
boxShadow: "0 10px 24px -10px rgba(0,0,0,0.8), inset 0 1px 0 rgba(255,255,255,0.06)",
}}
>
{children}
</button>
);
}
const ICON = { width: 22, height: 22, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2.2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true } as const;
/* ---- The component -------------------------------------------------------- */
export function SwipeCardStack({
threshold = 120,
stackDepth = 3,
cards = DEMO_CARDS,
loop = true,
likeLabel = "LIKE",
nopeLabel = "NOPE",
onSwipe,
onUndo,
onEnd,
className = "",
}: SwipeCardStackProps) {
const reduce = useReducedMotion();
const engRef = useRef<Eng | null>(null);
if (engRef.current === null) engRef.current = makeEng(cards.length);
const deckRef = useRef<HTMLDivElement>(null);
const [view, setView] = useState({ index: 0, liked: 0, passed: 0, msg: "" });
const n = cards.length;
const depthN = clamp(Math.round(stackDepth) || 1, 1, 6);
const canLoop = loop && n > 0;
useEffect(() => {
const e = engRef.current;
if (!e) return;
e.thr = Math.max(20, threshold);
e.depth = depthN;
e.loop = canLoop;
e.n = n;
e.reduce = reduce;
e.wake?.();
}, [threshold, depthN, canLoop, n, reduce]);
useEffect(() => {
const e = engRef.current;
const deck = deckRef.current;
if (!e || !deck) return;
e.deck = deck;
e.ready = true;
e.W = deck.offsetWidth || 256;
const tick = (t: number) => {
e.raf = 0;
const dt = e.last ? Math.min(0.034, (t - e.last) / 1000) : 0.016;
e.last = t;
if (step(e, dt)) e.raf = requestAnimationFrame(tick);
else e.last = 0;
};
e.wake = () => {
if (!e.raf) e.raf = requestAnimationFrame(tick);
};
const ro = new ResizeObserver(() => {
e.W = deck.offsetWidth || e.W;
e.wake?.();
});
ro.observe(deck);
e.wake();
return () => {
ro.disconnect();
if (e.raf) cancelAnimationFrame(e.raf);
e.raf = 0;
e.last = 0;
e.wake = null;
e.ready = false;
};
}, []);
function cardAt(i: number): SwipeCard | undefined {
return n === 0 ? undefined : cards[((i % n) + n) % n];
}
function sync(msg: string) {
const e = engRef.current;
if (!e) return;
let liked = 0;
for (const d of e.log) if (d === 1) liked++;
setView({ index: e.idx, liked, passed: e.log.length - liked, msg });
}
function toss(i: number, dir: Dir, v: number) {
const e = engRef.current;
if (!e || i !== e.idx || (!canLoop && i >= n)) return;
const card = cardAt(i);
const rec = e.recs.get(i);
if (rec) {
rec.out = dir;
rec.drag = false;
rec.vx = dir * Math.max(Math.abs(v), 700);
rec.lift = clamp(rec.y, -80, 80) - 26;
}
e.dirs.set(i, dir);
e.log.push(dir);
e.idx = i + 1;
e.wake?.();
const left = canLoop ? -1 : n - e.idx;
const t = card?.title ?? "Card";
sync((dir === 1 ? "Liked " : "Passed on ") + t + (left >= 0 ? ". " + left + (left === 1 ? " card left." : " cards left.") : "."));
if (card) onSwipe?.(card, dir === 1 ? "right" : "left");
if (!canLoop && e.idx >= n) onEnd?.();
}
function swipe(dir: Dir) {
const e = engRef.current;
if (e) toss(e.idx, dir, 0);
}
function undo() {
const e = engRef.current;
if (!e || e.idx <= 0) return;
e.log.pop();
e.idx -= 1;
const rec = e.recs.get(e.idx);
if (rec) {
rec.out = 0;
rec.lift = -26;
}
e.wake?.();
const card = cardAt(e.idx);
sync("Undid " + (card?.title ?? "last card") + ".");
if (card) onUndo?.(card);
}
function restart() {
const e = engRef.current;
if (!e) return;
e.log = [];
e.dirs.clear();
e.idx = 0;
e.recs.forEach((r) => {
r.out = 0;
r.lift = -26;
});
e.wake?.();
sync("Deck restarted.");
}
function hint(d: -1 | 1, on: boolean) {
const e = engRef.current;
if (!e) return;
e.hint = on ? d : e.hint === d ? 0 : e.hint;
e.wake?.();
}
function onKey(ev: ReactKeyboardEvent<HTMLDivElement>) {
if (ev.target !== ev.currentTarget || ev.altKey) return;
if (ev.key === "ArrowRight") swipe(1);
else if (ev.key === "ArrowLeft") swipe(-1);
else if (ev.key === "Backspace" || ev.key === "ArrowUp" || ((ev.metaKey || ev.ctrlKey) && ev.key.toLowerCase() === "z")) undo();
else return;
ev.preventDefault();
}
const ended = !canLoop && view.index >= n;
const lo = Math.max(0, view.index - 3);
const hi = canLoop ? view.index + depthN : Math.min(n, view.index + depthN);
const slots: number[] = [];
for (let i = lo; i < hi; i++) slots.push(i);
const topCard = cardAt(view.index);
const endRef = useRef<HTMLDivElement>(null);
useEffect(() => {
endRef.current?.toggleAttribute("inert", !ended);
}, [ended]);
return (
<div className={"relative w-64 max-w-full select-none pt-9 " + className}>
<div
ref={deckRef}
tabIndex={0}
role="group"
aria-roledescription="card stack"
aria-label={
ended
? "Card stack, all " + n + " cards reviewed."
: "Card stack, card " + Math.min(view.index + 1, n) + " of " + n + ". Left and right arrow keys pass or like, Backspace undoes."
}
aria-keyshortcuts="ArrowLeft ArrowRight Backspace"
onKeyDown={onKey}
className="relative aspect-[4/5] w-full rounded-[28px] outline-none focus-visible:!rounded-[28px] focus-visible:!outline-2 focus-visible:!outline-offset-[6px] focus-visible:!outline-[#7c8cff]"
>
{slots.map((i) => {
const card = cardAt(i);
if (!card) return null;
return (
<DeckCard
key={i}
i={i}
card={card}
engRef={engRef}
index={view.index}
position={(i % Math.max(1, n)) + 1}
total={n}
likeLabel={likeLabel}
nopeLabel={nopeLabel}
onToss={toss}
/>
);
})}
<div
ref={endRef}
aria-hidden={!ended}
className="absolute inset-0 z-[70] flex flex-col items-center justify-center gap-3 rounded-[28px] px-6 text-center transition-[opacity,transform] duration-500 motion-reduce:transition-none"
style={{
opacity: ended ? 1 : 0,
transform: ended ? "scale(1)" : "scale(0.94)",
pointerEvents: ended ? "auto" : "none",
background: "radial-gradient(100% 70% at 50% 0%, rgba(255,255,255,0.07), rgba(255,255,255,0) 70%), #0b0c0f",
boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.1), 0 28px 50px -18px rgba(0,0,0,0.8)",
transitionTimingFunction: "cubic-bezier(0.22, 1, 0.36, 1)",
}}
>
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" aria-hidden>
<circle cx="32" cy="32" r="30" stroke="rgba(255,255,255,0.14)" />
<circle cx="32" cy="32" r="21" stroke="rgba(255,255,255,0.22)" strokeDasharray="3 5" />
<path d="M23 33.5l7 7 12-14" stroke={LIKE} strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<p className="text-[20px] font-semibold tracking-tight text-white">All caught up</p>
<p className="text-[13px] leading-snug text-white/55">
{view.liked} liked, {view.passed} passed.
<br />
That was the whole deck.
</p>
<button
type="button"
onClick={restart}
className="mt-1 min-h-10 rounded-full bg-white px-5 text-[13px] font-medium text-black transition-[transform,background-color] duration-150 hover:bg-white/90 active:scale-95 motion-reduce:transition-none focus-visible:!rounded-full focus-visible:!outline-2 focus-visible:!outline-offset-2 focus-visible:!outline-[#7c8cff]"
>
Start over
</button>
</div>
</div>
<div className="mt-9 flex items-center justify-center gap-5">
<RoundButton
label={"Pass on " + (topCard?.title ?? "card")}
color={NOPE}
size={56}
disabled={ended}
onClick={() => swipe(-1)}
onHint={(on) => hint(-1, on)}
>
<svg {...ICON}>
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</RoundButton>
<RoundButton label="Undo last swipe" color="rgba(255,255,255,0.7)" size={44} disabled={view.index === 0} onClick={undo}>
<svg {...ICON} width={18} height={18}>
<path d="M9 14L4 9l5-5" />
<path d="M4 9h10a6 6 0 010 12h-3" />
</svg>
</RoundButton>
<RoundButton
label={"Like " + (topCard?.title ?? "card")}
color={LIKE}
size={56}
disabled={ended}
onClick={() => swipe(1)}
onHint={(on) => hint(1, on)}
>
<svg {...ICON}>
<path d="M12 20s-7-4.4-7-10a4 4 0 017-2.6A4 4 0 0119 10c0 5.6-7 10-7 10z" />
</svg>
</RoundButton>
</div>
<div aria-live="polite" role="status" className="sr-only">
{view.msg}
</div>
</div>
);
}
Free. Pro unlocks everything, from $27/month.
Details
About this pattern
A swipe deck with no dependencies. Pointer events drag the top card; its rotation is tied to its x offset (a little stronger when you grab it high), a LIKE or NOPE stamp and a colour wash fade in as it nears the threshold, and the cards underneath rise toward the front in step with the drag. On release the component projects where the card is heading from your last 90ms of velocity: past the threshold, it is thrown off the stack carrying that speed; short of it, it springs home with the same momentum. All motion runs on a single rAF loop of semi-implicit springs (stiffness 170, damping 22 for settling, 48 and 11.5 for a throw) that goes to sleep as soon as every card is at rest. Pass, Undo and Like buttons do the same jobs without a pointer (hovering a button leans the card toward that side), the arrow keys and Backspace work from the focused deck, and undo flies the last card back in. When the deck runs out a finished end state shows the liked and passed counts with a Start over button that deals the cards back. Props: threshold, stackDepth, your own cards (title, subtitle, meta, tag, hue for generated gradient artwork), loop (default true: the deck cycles forever; false gives a finite deck with the end state), custom stamp labels, and onSwipe, onUndo and onEnd callbacks. The root is w-64 by default; pass a className to resize it. React only, nothing to install.
Curator’s note
Rebuilt in October without Framer Motion: one requestAnimationFrame loop springs every card, so the deck, the throw and the undo all share the same physics.
