## Swipe Deck — Gesture A stack you decide through. Docs: https://www.interior.dev/docs/swipe-deck Reference: https://www.interior.dev/reference/swipe-deck License: https://github.com/ddoemonn/interior/blob/main/LICENSE (MIT) ### Install Requires a React project. Install `motion`; the styled example uses Tailwind CSS utilities. The source file is copied into your project. `bunx shadcn@latest add https://www.interior.dev/r/swipe-deck.json` Or run `bun add motion` and copy the source below. ### Usage ```tsx "use client"; import { useState } from "react"; import { SwipeDeck, type SwipeChoice } from "@/components/interior/swipe-deck"; type Ticket = { id: string; subject: string; from: string }; export function TriageQueue({ tickets }: { tickets: Ticket[] }) { const [routed, setRouted] = useState>({}); return ( t.id} itemLabel={(t) => `${t.subject}, from ${t.from}`} label="Support triage" leftLabel="Archive" rightLabel="Escalate" emptyLabel="Queue empty. Undo reopens the last ticket." height={168} threshold={104} onDecide={(t, choice) => { setRouted((prev) => ({ ...prev, [t.id]: choice })); void fetch(`/api/tickets/${t.id}`, { method: "PATCH", body: JSON.stringify({ state: choice === "right" ? "escalated" : "archived" }), }); }} onUndo={(t) => { setRouted((prev) => { const next = { ...prev }; delete next[t.id]; return next; }); void fetch(`/api/tickets/${t.id}`, { method: "PATCH", body: JSON.stringify({ state: "open" }), }); }} > {(t) => (

{t.subject}

{t.from}

)}
); } ``` ### Props - `items` (`readonly T[]`) The queue, in order. The deck consumes it from the front; nothing is mutated. - `itemKey` (`(item: T) => string`) Stable identity per card. Drives React keys and presence, so it must not change between renders. - `itemLabel` (`(item: T) => string`) One-line summary. Labels the card for assistive tech and is what the live region announces once per settled card. - `children` (`(item: T) => React.ReactNode`) Render prop for the card face. Receives a box of exactly `height` pixels; leave the top 24px clear so the decision stamp has somewhere to land. - `onDecide` (`(item: T, choice: SwipeChoice) => void`) — default: `undefined` Fired once when a card commits, by drag, flick, arrow key or button. - `onUndo` (`(item: T) => void`) — default: `undefined` Fired when a decision is reversed, with the item that came back. - `label` (`string`) — default: `"Card deck"` Accessible name for the deck group. - `leftLabel` (`string`) — default: `"Skip"` Left decision. Used on the button and on the stamp that fades in as you drag left. - `rightLabel` (`string`) — default: `"Keep"` Right decision, same treatment mirrored. - `undoLabel` (`string`) — default: `"Undo"` Label on the undo control, which is always present and disabled rather than absent. - `emptyLabel` (`string`) — default: `"Deck cleared"` Shown in the reserved box once the queue is spent. - `height` (`number`) — default: `180` Card height in pixels. The deck reserves height + 26 so the fanned stack never overflows. - `threshold` (`number`) — default: `92` Horizontal pixels a card must travel to commit. Also the denominator for the intent cells. - `steps` (`number`) — default: `6` How many cells the approach to the threshold is quantised into, per side. - `peek` (`number`) — default: `3` Cards mounted at once. Everything past this is not in the DOM. - `className` (`string`) — default: `""` Appended last to the outer wrapper. ### Behavior notes - The deck reserves height + 26 pixels before the first card is drawn, and every card, the fanned stack behind it and the empty state are absolutely positioned inside that one rectangle, so a decision never moves the page underneath it. - Drag distance is reported as one of six discrete steps per side rather than a float, so a 300-pixel swipe costs six renders instead of one per frame, and the cells state where the commit point is before you cross it rather than after. - A card that snaps back was never decided: the commit rule lives in useSwipeDeck and asks for either the full threshold or a genuine flick, so a card cannot leave on a twitch. - Every decision is kept, so Undo restores the card and flies it back in from the side it left; a mis-swipe costs one keystroke, not a lost record. - The deck is a focusable group where the arrow keys decide and Backspace reverses, and the same three actions exist as real buttons, so the interaction is not reachable by pointer alone. - Screen readers get one polite announcement per settled card, its label and its position in the queue, never the drag offset; under prefers-reduced-motion the card still leaves and the counter still moves, only the fly-out is skipped. ### Source (`components/interior/swipe-deck.tsx`) ```tsx "use client"; import { useCallback, useEffect, useId, useRef, useState } from "react"; import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, useTransform, } from "motion/react"; const CELL = { type: "spring", stiffness: 520, damping: 34, mass: 0.45 } as const; const DISCLOSE = { type: "spring", stiffness: 150, damping: 27, mass: 1 } as const; const CROSSFADE = { type: "spring", stiffness: 260, damping: 34, mass: 0.8 } as const; const LEAVE = [0.4, 0, 1, 1] as const; export type SwipeChoice = "left" | "right"; export type SwipeIntent = { dir: -1 | 0 | 1; step: number }; export type SwipeDeckFlow = { dir: -1 | 1; kind: "decide" | "undo" }; const BLANK: SwipeIntent = { dir: 0, step: 0 }; const spent = (out: boolean) => (out ? "opacity-0" : ""); export type UseSwipeDeckOptions = { count: number; threshold?: number; steps?: number; flick?: number; onDecide?: (index: number, choice: SwipeChoice) => void; onUndo?: (index: number) => void; disabled?: boolean; }; export function useSwipeDeck({ count, threshold = 92, steps = 6, flick = 520, onDecide, onUndo, disabled = false, }: UseSwipeDeckOptions) { const total = Math.max(0, Math.floor(count)); const grain = Math.max(1, Math.floor(steps)); const reach = Math.max(1, threshold); const [decisions, setDecisions] = useState([]); const [flow, setFlow] = useState({ dir: 1, kind: "decide" }); const [intent, setIntent] = useState(BLANK); const index = Math.min(decisions.length, total); const len = useRef(decisions.length); len.current = decisions.length; const size = useRef(total); size.current = total; const made = useRef(decisions); made.current = decisions; const decided = useRef(onDecide); decided.current = onDecide; const reverted = useRef(onUndo); reverted.current = onUndo; const clear = useCallback(() => { setIntent((prev) => (prev.step === 0 && prev.dir === 0 ? prev : BLANK)); }, []); const decide = useCallback( (choice: SwipeChoice) => { if (disabled) return; const at = len.current; if (at >= size.current) return; len.current = at + 1; setDecisions((prev) => [...prev, choice]); setFlow({ dir: choice === "right" ? 1 : -1, kind: "decide" }); setIntent(BLANK); decided.current?.(at, choice); }, [disabled], ); const undo = useCallback(() => { if (disabled) return; const at = len.current; if (at === 0) return; const last = made.current[at - 1]; len.current = at - 1; setDecisions((prev) => prev.slice(0, prev.length - 1)); setFlow({ dir: last === "right" ? 1 : -1, kind: "undo" }); setIntent(BLANK); reverted.current?.(at - 1); }, [disabled]); const report = useCallback( (dx: number) => { const step = Math.min(grain, Math.round((Math.abs(dx) / reach) * grain)); const dir: -1 | 0 | 1 = step === 0 ? 0 : dx > 0 ? 1 : -1; setIntent((prev) => prev.dir === dir && prev.step === step ? prev : { dir, step }, ); }, [grain, reach], ); const release = useCallback( (dx: number, vx: number) => { const far = Math.abs(dx) >= reach; const fast = Math.abs(vx) >= flick && Math.abs(dx) >= reach * 0.35; if (!far && !fast) { clear(); return; } decide((far ? dx : vx) > 0 ? "right" : "left"); }, [reach, flick, clear, decide], ); const onKeyDown = useCallback( (event: React.KeyboardEvent) => { if (event.target !== event.currentTarget) return; if (event.key === "ArrowLeft") { event.preventDefault(); decide("left"); } else if (event.key === "ArrowRight") { event.preventDefault(); decide("right"); } else if (event.key === "Backspace" || event.key === "Delete") { event.preventDefault(); undo(); } else if (event.key === "Escape") { clear(); } }, [decide, undo, clear], ); useEffect(() => { const bail = () => clear(); const hidden = () => document.hidden && clear(); window.addEventListener("blur", bail); document.addEventListener("visibilitychange", hidden); return () => { window.removeEventListener("blur", bail); document.removeEventListener("visibilitychange", hidden); }; }, [clear]); return { index, count: total, remaining: total - index, done: index >= total, decisions, flow, intent, steps: grain, threshold: reach, armed: intent.step >= grain, canUndo: decisions.length > 0, decide, undo, clear, report, release, deckProps: { role: "group" as const, "aria-roledescription": "card deck", tabIndex: 0, onKeyDown, }, }; } export type UseSwipeDeckResult = ReturnType; const ICON_LEFT = ( ); const ICON_RIGHT = ( ); const ICON_UNDO = ( ); type DeckCardProps = { depth: number; height: number; entryX: number; active: boolean; reduced: boolean; label: string; leftLabel: string; rightLabel: string; intent: SwipeIntent; steps: number; onMove: (dx: number) => void; onRelease: (dx: number, vx: number) => void; children: React.ReactNode; }; function DeckCard({ depth, height, entryX, active, reduced, label, leftLabel, rightLabel, intent, steps, onMove, onRelease, children, }: DeckCardProps) { const x = useMotionValue(entryX); const rotate = useTransform(x, [-200, 0, 200], [-8, 0, 8], { clamp: false }); const fade = useTransform(x, [-340, -150, 0, 150, 340], [0, 1, 1, 1, 0]); const skip = useRef(reduced); skip.current = reduced; useEffect(() => { if (x.get() === 0) return; const controls = animate(x, 0, skip.current ? { duration: 0 } : DISCLOSE); return () => controls.stop(); }, [x, entryX]); const commit = active ? 0 : depth === 1 ? intent.step / steps : 0; const y = depth * 10 - commit * 10; const scale = 1 - depth * 0.045 + commit * 0.045; const badge = (side: -1 | 1, text: string, place: string) => { const on = intent.dir === side; return ( = steps ? "border-[#4568FF] text-[#4568FF] dark:border-[#93B0FF] dark:text-[#93B0FF]" : "border-stone-300 text-stone-700 dark:border-white/20 dark:text-stone-200" } ${place}`} > {text} ); }; return ( ({ x: dir * 560, zIndex: 12, borderColor: "rgba(0,0,0,0)", transition: reduced ? { duration: 0 } : { x: { duration: 0.3, ease: LEAVE }, borderColor: { duration: 0.1, ease: "linear" }, }, }), }} initial={{ y, scale }} animate={{ y, scale }} exit="exit" transition={ reduced ? { duration: 0 } : active ? { ...CROSSFADE, delay: 0.1 } : CROSSFADE } drag={active ? "x" : false} dragDirectionLock dragMomentum={false} dragElastic={1} dragConstraints={{ left: 0, right: 0 }} dragTransition={{ bounceStiffness: 260, bounceDamping: 34 }} whileDrag={reduced ? undefined : { scale: 1.03 }} onDrag={(_event, info) => onMove(info.offset.x)} onDragEnd={(_event, info) => onRelease(info.offset.x, info.velocity.x)} style={{ x, rotate, opacity: fade, height, zIndex: 10 - depth, transformOrigin: "50% 100%", touchAction: "pan-y", }} className={`absolute inset-x-5 top-0 select-none overflow-hidden rounded-[14px] border border-stone-200 bg-white dark:border-white/[0.16] dark:bg-[#1D1D1A] ${ active ? "cursor-grab shadow-[0_1px_2px_rgba(28,25,23,0.06),0_16px_32px_-18px_rgba(28,25,23,0.55)] active:cursor-grabbing dark:shadow-[0_2px_16px_rgba(0,0,0,0.6)]" : "shadow-[0_1px_2px_rgba(28,25,23,0.05),0_6px_14px_-12px_rgba(28,25,23,0.4)] dark:shadow-[0_1px_6px_rgba(0,0,0,0.45)]" }`} > {children} {active ? badge(-1, leftLabel, "left-3") : null} {active ? badge(1, rightLabel, "right-3") : null} ); } export type SwipeDeckProps = { items: readonly T[]; itemKey: (item: T) => string; itemLabel: (item: T) => string; children: (item: T) => React.ReactNode; onDecide?: (item: T, choice: SwipeChoice) => void; onUndo?: (item: T) => void; label?: string; leftLabel?: string; rightLabel?: string; undoLabel?: string; emptyLabel?: string; height?: number; threshold?: number; steps?: number; peek?: number; className?: string; }; export function SwipeDeck({ items, itemKey, itemLabel, children, onDecide, onUndo, label = "Card deck", leftLabel = "Skip", rightLabel = "Keep", undoLabel = "Undo", emptyLabel = "Deck cleared", height = 180, threshold = 92, steps = 6, peek = 3, className = "", }: SwipeDeckProps) { const hintId = useId(); const reduced = useReducedMotion() === true; const deck = useSwipeDeck({ count: items.length, threshold, steps, onDecide: (at, choice) => { const item = items[at]; if (item !== undefined) onDecide?.(item, choice); }, onUndo: (at) => { const item = items[at]; if (item !== undefined) onUndo?.(item); }, }); const stack = items.slice(deck.index, deck.index + Math.max(1, peek)); const current = items[deck.index]; const control = "inline-flex h-8 items-center gap-1.5 rounded-[9px] border border-stone-200 bg-white px-2.5 text-[12px] font-medium text-stone-700 outline-none transition-[background-color,border-color,opacity] duration-150 hover:bg-stone-100 focus-visible:border-[#4568FF] dark:border-white/[0.16] dark:bg-[#1D1D1A] dark:text-stone-200 dark:hover:bg-white/10 dark:focus-visible:border-[#93B0FF]"; return (
{emptyLabel} {stack.map((item, depth) => ( {children(item)} ))}
{items.length} {deck.remaining} left

{deck.done || current === undefined ? emptyLabel : `${itemLabel(current)}. Card ${deck.index + 1} of ${items.length}.`}

Left and right arrow keys decide the top card. Backspace brings the last one back.
); } ```