{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"card-stack","type":"registry:ui","title":"Card stack","description":"Review a deck one card at a time, with a throw and an undo.","categories":["data"],"dependencies":["lucide-react","motion"],"registryDependencies":["https://uiarc.dev/r/arc-foundation.json"],"files":[{"path":"registry/components/card-stack/card-stack.tsx","type":"registry:component","target":"@components/arc/card-stack/card-stack.tsx","content":"\"use client\";\n\nimport { useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore } from \"react\";\nimport type { KeyboardEvent, PointerEvent as ReactPointerEvent, ReactNode } from \"react\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, useSpring, useTransform } from \"motion/react\";\nimport type { MotionValue } from \"motion/react\";\nimport { Check, RotateCcw, X } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./card-stack.module.css\";\n\nexport type CardDecision = \"left\" | \"right\";\n\n/**\n * A deck of cards reviewed one at a time: drag or flick the top card left or right, or use the buttons and arrow keys.\n * Use it for quick, one-by-one triage where each item deserves a moment of attention: shortlisting destinations, candidates, or design options.\n * Use a list or table when people need to compare items side by side.\n */\nexport interface CardStackProps {\n items: T[];\n getKey: (item: T) => string;\n /** A short name for the card, used in its accessible label and announcements, such as \"Lisbon\". */\n getLabel: (item: T) => string;\n renderCard: (item: T) => ReactNode;\n onDecide?: (item: T, decision: CardDecision) => void;\n onUndo?: (item: T, decision: CardDecision) => void;\n onReset?: () => void;\n /** Action names for each side, used on the buttons and the stamps that appear while dragging. */\n labels?: { left: string; right: string };\n /** Past tense of each action for announcements, such as \"skipped\" and \"shortlisted\". */\n outcomes?: { left: string; right: string };\n /** Accessible name for the stack. */\n label?: string;\n /** Shown once every card has been decided. Receives a function that brings all cards back. */\n renderEmpty?: (reset: () => void) => ReactNode;\n className?: string;\n}\n\ntype Point = { x: number; y: number };\ntype Exit = Point & { tilt: number };\ntype Return = Exit & { delay: number };\n\n/** Cards rendered in the stack. The last one waits invisibly one step behind and fades in as it rises. */\nconst VISIBLE = 4;\n/** How far each card behind peeks out below the one in front, and how much smaller it is. */\nconst PEEK = 12;\nconst SHRINK = .05;\n/** How far the cards behind rise while the top card is dragged away, in stack steps. */\nconst LIFT = .4;\n/** Drag distance at which the cards behind have fully risen. */\nconst LIFT_DISTANCE = 160;\n/** Degrees of rotation per pixel of horizontal travel. */\nconst ROTATE = .065;\n/** A release faster than this (px/s) throws the card even when it has only moved a little. */\nconst FLICK = 500;\n\n/** Vertical travel is not a decision, so past a small free zone it resists like a rubber band. */\nconst FREE_Y = 48;\nconst STRETCH_Y = 140;\n\nconst noop = () => () => {};\nconst resistY = (raw: number) => {\n const distance = Math.abs(raw);\n return distance <= FREE_Y ? raw : Math.sign(raw) * (FREE_Y + (1 - 1 / ((distance - FREE_Y) * .55 / STRETCH_Y + 1)) * STRETCH_Y);\n};\nconst unresistY = (shown: number) => {\n const distance = Math.abs(shown);\n if (distance <= FREE_Y) return shown;\n const stretch = Math.min(distance - FREE_Y, STRETCH_Y - 1);\n return Math.sign(shown) * (FREE_Y + (1 / (1 - stretch / STRETCH_Y) - 1) * STRETCH_Y / .55);\n};\nconst clampUnit = (value: number) => Math.min(1, Math.max(0, value));\nconst throwSpring = { type: \"spring\", visualDuration: .5, bounce: 0 } as const;\nconst fadeOut = { duration: .22, delay: .12, ease: [...motionTokens.ease.standard] } as const;\nconst reducedFade = { duration: .15, ease: \"linear\" } as const;\n\nfunction velocityOf(samples: { t: number; x: number; y: number }[], now: number): Point {\n const recent = samples.filter(sample => now - sample.t <= 80);\n const first = recent[0], last = recent[recent.length - 1];\n if (!first || !last || first === last || now - last.t > 60) return { x: 0, y: 0 };\n const seconds = (last.t - first.t) / 1000;\n return { x: (last.x - first.x) / seconds, y: (last.y - first.y) / seconds };\n}\n\ninterface CardProps {\n id: string;\n depth: number;\n decision: CardDecision | null;\n returnFrom?: Return;\n interactive: boolean;\n zIndex: number;\n label: string;\n labels: { left: string; right: string };\n lift: MotionValue;\n reduced: boolean;\n fling: (id: string) => Point | undefined;\n onRelease: (id: string, offset: Point, velocity: Point, width: number) => boolean;\n onThrown: (id: string, exit: Exit) => void;\n onGone: (id: string) => void;\n children: ReactNode;\n}\n\nfunction StackCard({ id, depth, decision, returnFrom, interactive, zIndex, label, labels, lift, reduced, fling, onRelease, onThrown, onGone, children }: CardProps) {\n const cardRef = useRef(null);\n // A card brought back by undo starts where it left the screen; with reduced motion it simply fades in at home.\n const travel = returnFrom && !reduced ? returnFrom : undefined;\n const x = useMotionValue(travel?.x ?? 0);\n const y = useMotionValue(travel?.y ?? 0);\n const tilt = useMotionValue(travel?.tilt ?? 1);\n const opacity = useMotionValue(returnFrom ? 0 : 1);\n const rotate = useTransform([x, tilt], ([offset, sign]: number[]) => offset * ROTATE * sign);\n // Depth springs toward its slot; while the top card is dragged away, the cards behind already start to rise.\n const depthTarget = useTransform(lift, value => depth - (depth > 0 ? value * LIFT : 0));\n const depthSpring = useSpring(depthTarget, motionTokens.spring.smooth);\n const shown = reduced ? depthTarget : depthSpring;\n const scale = useTransform(shown, value => 1 - value * SHRINK);\n const lower = useTransform(shown, value => value * PEEK);\n const presence = useTransform(shown, [VISIBLE - 2, VISIBLE - 1], [1, 0]);\n const shade = useTransform(shown, [0, 2], [0, 1]);\n const keep = useTransform(x, [16, 96], [0, 1]);\n const pass = useTransform(x, [-96, -16], [1, 0]);\n const drag = useRef<{ ox: number; oy: number; samples: { t: number; x: number; y: number }[] } | null>(null);\n const settled = useRef(!returnFrom);\n const wasDecided = useRef(false);\n // A card flying home from undo or start over leaves the stack alone: the cards behind make room in one motion instead of\n // pausing half-risen until it lands. Grabbing it mid-flight hands the stack back to the drag.\n const homing = useRef(false);\n\n // The top card reports how far it has travelled, so the stack behind responds to the drag and the throw.\n useEffect(() => {\n if (!interactive) return;\n const update = () => { if (!homing.current) lift.set(clampUnit(Math.hypot(x.get(), y.get() * .5) / LIFT_DISTANCE)); };\n const offX = x.on(\"change\", update), offY = y.on(\"change\", update);\n return () => { offX(); offY(); };\n }, [interactive, lift, x, y]);\n\n useLayoutEffect(() => {\n if (decision) {\n // A key or button can decide the card mid-drag; the throw takes over from the finger.\n drag.current = null;\n homing.current = false;\n cardRef.current?.removeAttribute(\"data-dragging\");\n wasDecided.current = true;\n const direction = decision === \"right\" ? 1 : -1;\n const width = cardRef.current?.offsetWidth ?? 320;\n const cx = x.get(), cy = y.get();\n // Buttons and keys throw with a default flick; a drag keeps the finger's velocity.\n const velocity = fling(id) ?? { x: direction * 900, y: -120 };\n const speed = Math.hypot(velocity.x, velocity.y);\n // Leave along the flick when it heads the chosen way, otherwise along the drag, otherwise straight out.\n let ux = velocity.x, uy = velocity.y;\n if (speed < 200 || Math.sign(ux) !== direction) { ux = Math.abs(cx) > 8 ? cx : direction; uy = Math.abs(cx) > 8 ? cy * .5 : -.12; }\n const length = Math.hypot(ux, uy) || 1;\n ux /= length; uy /= length;\n if (Math.abs(ux) < .45) { ux = direction * .45; uy = Math.sign(uy || -1) * Math.sqrt(1 - .45 * .45); }\n // Far enough to clear a typical preview or card column; the fade finishes before the spring does.\n const reach = width * 1.6;\n const exit = { x: cx + ux * reach, y: cy + uy * reach, tilt: tilt.get() };\n onThrown(id, exit);\n if (reduced) { animate(opacity, 0, { ...reducedFade, onComplete: () => onGone(id) }); return; }\n animate(x, exit.x, { ...throwSpring, velocity: velocity.x });\n animate(y, exit.y, { ...throwSpring, velocity: velocity.y });\n animate(opacity, 0, { ...fadeOut, onComplete: () => onGone(id) });\n return;\n }\n // Undo, or a first mount from where the card left: come back from there, catching any flight in progress.\n if (!wasDecided.current && settled.current) return;\n wasDecided.current = false;\n settled.current = true;\n const delay = returnFrom?.delay ?? 0;\n if (reduced) { x.jump(0); y.jump(0); animate(opacity, 1, { ...reducedFade, delay }); return; }\n homing.current = true;\n animate(x, 0, { ...motionTokens.spring.smooth, delay, onComplete: () => { homing.current = false; } });\n animate(y, 0, { ...motionTokens.spring.smooth, delay });\n animate(opacity, 1, { duration: motionTokens.duration.fast, delay, ease: [...motionTokens.ease.enter] });\n // Only the decision drives this effect; the return position is read once at that moment.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [decision]);\n\n function down(event: ReactPointerEvent) {\n if (!interactive || event.button !== 0 || !event.isPrimary) return;\n if (event.target instanceof Element && event.target.closest(\"button, a, input, select, textarea, [role='button']\")) return;\n x.stop(); y.stop();\n homing.current = false;\n // Grabbing the top half tips the card the way the hand moves; grabbing the bottom half tips it the other way.\n if (Math.abs(x.get()) < 2) {\n const rect = event.currentTarget.getBoundingClientRect();\n tilt.set(event.clientY < rect.top + rect.height / 2 ? 1 : -1);\n }\n drag.current = { ox: event.clientX - x.get(), oy: event.clientY - unresistY(y.get()), samples: [{ t: event.timeStamp, x: event.clientX, y: event.clientY }] };\n event.currentTarget.setPointerCapture(event.pointerId);\n event.currentTarget.setAttribute(\"data-dragging\", \"\");\n }\n function move(event: ReactPointerEvent) {\n const state = drag.current;\n if (!state || !interactive) return;\n x.set(event.clientX - state.ox);\n y.set(resistY(event.clientY - state.oy));\n state.samples.push({ t: event.timeStamp, x: event.clientX, y: event.clientY });\n if (state.samples.length > 12) state.samples.shift();\n }\n function up(event: ReactPointerEvent) {\n const state = drag.current;\n drag.current = null;\n event.currentTarget.removeAttribute(\"data-dragging\");\n if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);\n if (!state || !interactive) return;\n const velocity = event.type === \"pointercancel\" ? { x: 0, y: 0 } : velocityOf(state.samples, event.timeStamp);\n if (onRelease(id, { x: x.get(), y: y.get() }, velocity, event.currentTarget.offsetWidth)) return;\n // Below the threshold the card springs home, keeping the release velocity so it settles instead of stopping dead.\n if (reduced) { x.jump(0); y.jump(0); return; }\n animate(x, 0, { ...motionTokens.spring.morph, velocity: velocity.x });\n animate(y, 0, { ...motionTokens.spring.morph, velocity: velocity.y });\n }\n\n const flying = decision !== null;\n return \n \n
\n {children}\n \n
\n {labels.right}\n {labels.left}\n
\n ;\n}\n\nfunction Control({ children, onClick, disabled, reduced, tone }: { children: ReactNode; onClick: () => void; disabled: boolean; reduced: boolean; tone?: \"primary\" | \"quiet\" }) {\n // aria-disabled keeps focus on the button when the stack runs out, instead of dropping it to the page.\n return { if (!disabled) onClick(); }} whileTap={{ scale: reduced || disabled ? 1 : .97 }} transition={motionTokens.spring.snappy}>{children};\n}\n\n/** Renders a custom empty state; the reset handler travels as a prop so render never touches the stack's refs. */\nfunction EmptySlot({ render, onReset }: { render: (reset: () => void) => ReactNode; onReset: () => void }) { return <>{render(onReset)}; }\n\nexport function CardStack({ items, getKey, getLabel, renderCard, onDecide, onUndo, onReset, labels = { left: \"Pass\", right: \"Keep\" }, outcomes, label = \"Cards\", renderEmpty, className }: CardStackProps) {\n const hydrated = useSyncExternalStore(noop, () => true, () => false);\n const reduced = !!useReducedMotion() && hydrated;\n const hintId = useId();\n const [position, setPosition] = useState(0);\n const [history, setHistory] = useState<{ key: string; decision: CardDecision }[]>([]);\n const [flying, setFlying] = useState([]);\n const [returning, setReturning] = useState>({});\n const [message, setMessage] = useState(\"\");\n const lift = useMotionValue(0);\n // Where each thrown card left the screen, so undo and start over can bring it back from there. Only those handlers read it,\n // so it lives in a ref: recording an exit must not re-render the stack in the very frame the throw starts.\n const exits = useRef>({});\n const flings = useRef(new Map());\n const top = items[position];\n const remaining = Math.max(0, items.length - position);\n\n function decide(decision: CardDecision) {\n if (!top) return;\n const key = getKey(top);\n lift.set(0);\n setPosition(position + 1);\n setHistory([...history, { key, decision }]);\n setFlying(current => [...current, key]);\n setReturning({});\n const left = remaining - 1;\n setMessage(`${getLabel(top)}${outcomes ? ` ${outcomes[decision]}` : `: ${labels[decision]}`}. ${left ? `${left} left.` : \"All cards reviewed.\"}`);\n onDecide?.(top, decision);\n }\n\n function undo() {\n const last = history[history.length - 1];\n const item = last && items.find(entry => getKey(entry) === last.key);\n if (!last || !item) return;\n const exit = exits.current[last.key];\n setPosition(position - 1);\n setHistory(history.slice(0, -1));\n setFlying(current => current.filter(key => key !== last.key));\n setReturning(exit ? { [last.key]: { ...exit, delay: 0 } } : {});\n setMessage(`${getLabel(item)} restored.`);\n onUndo?.(item, last.decision);\n }\n\n function reset() {\n if (!history.length) return;\n // The deck deals itself back: the card that will sit deepest returns first, the top card last.\n const count = Math.min(VISIBLE, items.length);\n const back: Record = {};\n items.slice(0, count).forEach((item, index) => {\n const key = getKey(item), exit = exits.current[key];\n if (exit && index < position) back[key] = { ...exit, delay: (count - 1 - index) * .06 };\n });\n setPosition(0);\n setHistory([]);\n setFlying([]);\n setReturning(back);\n setMessage(\"All cards are back in the stack.\");\n onReset?.();\n }\n\n function release(key: string, offset: Point, velocity: Point, width: number) {\n const direction = Math.sign(offset.x);\n if (!direction || !top || getKey(top) !== key) return false;\n const flick = Math.abs(velocity.x) > FLICK && Math.sign(velocity.x) === direction && Math.abs(offset.x) > 12;\n const far = Math.abs(offset.x) > Math.min(width * .35, 140) && !(Math.sign(velocity.x) === -direction && Math.abs(velocity.x) > 300);\n if (!flick && !far) return false;\n flings.current.set(key, velocity);\n decide(direction > 0 ? \"right\" : \"left\");\n return true;\n }\n\n function key(event: KeyboardEvent) {\n if (event.target !== event.currentTarget) return;\n if (event.key === \"ArrowLeft\" || event.key === \"ArrowRight\") { event.preventDefault(); decide(event.key === \"ArrowRight\" ? \"right\" : \"left\"); }\n else if (event.key === \"Backspace\" || ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === \"z\")) { event.preventDefault(); undo(); }\n }\n\n const decided = new Map(history.map(entry => [entry.key, entry.decision]));\n const windowKeys = new Set(items.slice(position, position + VISIBLE).map(getKey));\n const rendered = items.filter(item => windowKeys.has(getKey(item)) || flying.includes(getKey(item)));\n const empty = position >= items.length && flying.length === 0;\n\n return
\n
\n {rendered.map(item => {\n const id = getKey(item), index = items.indexOf(item), decision = decided.get(id) ?? null;\n const depth = decision ? 0 : index - position;\n return { const velocity = flings.current.get(cardKey); flings.current.delete(cardKey); return velocity; }}\n onRelease={release}\n onThrown={(cardKey, exit) => { exits.current[cardKey] = exit; }}\n onGone={cardKey => setFlying(current => current.filter(entry => entry !== cardKey))}\n >{renderCard(item)};\n })}\n \n {empty && \n {renderEmpty ? : <>\n

All cards reviewed

\n \n }\n
}\n
\n
\n
\n decide(\"left\")} disabled={!top} reduced={reduced}>{labels.left}\n Undo\n decide(\"right\")} disabled={!top} reduced={reduced} tone=\"primary\">{labels.right}\n
\n

Drag the top card, or use the left and right arrow keys to {labels.left.toLowerCase()} or {labels.right.toLowerCase()} it. Backspace undoes the last decision.

\n

{message}

\n
;\n}\n\nexport default CardStack;\n"},{"path":"registry/components/card-stack/card-stack.module.css","type":"registry:component","target":"@components/arc/card-stack/card-stack.module.css","content":".root { --card-height: 19rem; --stack-peek: 24px; display: grid; width: 100%; justify-items: center; gap: var(--space-5); font-family: var(--font-body); letter-spacing: var(--tracking-body); }\n\n/* The stage reserves the top card plus the edges peeking out below it, so nothing reflows as cards come and go. */\n.stage { position: relative; isolation: isolate; width: min(100%, 20rem); height: calc(var(--card-height) + var(--stack-peek)); outline: none; }\n/* Each slot scales from its bottom edge, so the cards behind show a thin, even edge below the one in front. */\n.slot { position: absolute; top: 0; right: 0; left: 0; height: var(--card-height); pointer-events: none; transform-origin: 50% 100%; }\n.card { position: absolute; inset: 0; border-radius: var(--radius-surface); pointer-events: auto; touch-action: pan-y; user-select: none; -webkit-user-select: none; -webkit-tap-highlight-color: transparent; }\n.card[data-top] { cursor: grab; }\n.card[data-dragging] { cursor: grabbing; }\n.card:not([data-top]) { pointer-events: none; }\n/* Resting cards sit flat on their borders; a card in the hand or in flight floats and casts a shadow. */\n.card::before { position: absolute; inset: 0; border-radius: inherit; box-shadow: var(--shadow-floating); content: \"\"; opacity: 0; pointer-events: none; transition: opacity var(--duration-fast) var(--ease-standard); }\n.card[data-dragging]::before, .card[data-flying]::before { opacity: 1; }\n.face { position: relative; height: 100%; overflow: hidden; border: 1px solid var(--border); border-radius: inherit; background: var(--surface); color: var(--foreground); }\n.shade { position: absolute; inset: 0; background: oklch(0% 0 0 / .035); pointer-events: none; }\n:global(:root[data-theme=\"dark\"]) .shade { background: oklch(0% 0 0 / .22); }\n\n.stamp { position: absolute; top: var(--space-4); display: inline-flex; height: 2rem; align-items: center; gap: 6px; padding: 0 var(--space-3) 0 10px; border: 1px solid var(--border); border-radius: var(--radius-pill); background: var(--surface-raised); color: var(--foreground); font-size: var(--text-sm); font-weight: 500; white-space: nowrap; pointer-events: none; }\n.stamp[data-side=\"right\"] { left: var(--space-4); border-color: color-mix(in oklch, var(--success) 32%, var(--border)); background: color-mix(in oklch, var(--success) 9%, var(--surface-raised)); }\n.stamp[data-side=\"right\"] svg { color: var(--success); }\n.stamp[data-side=\"left\"] { right: var(--space-4); }\n.stamp[data-side=\"left\"] svg { color: var(--text-secondary); }\n/* Cards returning to the back of the stack carry no verdict. */\n.card:not([data-top]):not([data-flying]) .stamp { visibility: hidden; }\n\n.stage:focus-visible .card[data-top], .stage:focus-visible .empty { outline: 2px solid var(--focus-ring); outline-offset: 3px; }\n\n.empty { position: absolute; top: 0; right: 0; left: 0; display: grid; height: var(--card-height); place-content: center; justify-items: center; gap: var(--space-3); padding: var(--space-6); border: 1px solid var(--border); border-radius: var(--radius-surface); background: var(--surface); text-align: center; }\n.emptyTitle { margin: 0; font-size: var(--text-base); font-weight: 500; }\n\n.controls { display: flex; flex-wrap: wrap; justify-content: center; gap: var(--space-2); }\n.control { display: inline-flex; min-height: var(--control-height-md); align-items: center; justify-content: center; gap: var(--space-2); padding: 0 var(--space-4); border: 1px solid var(--border); border-radius: var(--radius-control); background: var(--surface); color: var(--foreground); font: inherit; font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); white-space: nowrap; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: background-color var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), opacity var(--duration-fast) var(--ease-standard); }\n.control svg { flex: none; }\n.control[data-tone=\"primary\"] { border-color: var(--foreground); background: var(--foreground); color: var(--background); }\n.control[data-tone=\"quiet\"] { border-color: transparent; background: transparent; color: var(--text-secondary); }\n@media (hover: hover) and (pointer: fine) {\n .control:not([aria-disabled]):hover { background: var(--surface-muted); }\n .control[data-tone=\"primary\"]:not([aria-disabled]):hover { background: var(--foreground); opacity: .9; }\n .control[data-tone=\"quiet\"]:not([aria-disabled]):hover { color: var(--foreground); }\n}\n.control:not([aria-disabled]):active { background: var(--surface-muted); }\n.control[data-tone=\"primary\"]:not([aria-disabled]):active { background: var(--foreground); opacity: .84; }\n.control[aria-disabled] { cursor: default; opacity: .42; }\n.control:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }\n\n.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n\n@media (max-width: 26rem) {\n .root { --card-height: 17.5rem; gap: var(--space-4); }\n .controls { flex-wrap: nowrap; }\n .control { padding-inline: var(--space-3); }\n}\n/* On the narrowest screens Undo keeps its icon and its accessible name, so all three controls stay on one row. */\n@media (max-width: 22.5rem) {\n .control[data-tone=\"quiet\"] { width: var(--control-height-md); padding: 0; }\n .control[data-tone=\"quiet\"] .controlLabel { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .card::before, .control { transition: none; }\n}\n"}],"docs":"Docs and live preview: https://uiarc.dev/components/card-stack","meta":{"tier":"free","kind":"component","docs":"https://uiarc.dev/components/card-stack","markdown":"https://uiarc.dev/components/card-stack/markdown","tags":["gesture","motion","triage"]}}