{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"card-deck","type":"registry:component","title":"Card deck","description":"A tactile stack that fans out, follows your hand, and deals the next card with a measured flick.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/number-roll.json"],"files":[{"path":"registry/card-deck.tsx","type":"registry:component","content":"// Hairline UI · card-deck · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'use client';\n\nimport * as React from 'react';\nimport { ArrowLeft, ArrowRight } from 'lucide-react';\nimport { EASE_OUT, EASE_SETTLE, INTERACTION_FOCUS, useInteractionReducedMotion } from './ui-interaction-motion';\nimport { useCardDeckGesture, type DeckThrow } from './use-card-deck-gesture';\nimport { NumberRoll } from './number-roll';\n\nexport interface CardDeckProps<T extends { id: string }> extends Omit<React.HTMLAttributes<HTMLDivElement>, 'children' | 'onChange'> {\n  items: T[];\n  renderCard: (item: T, state: { active: boolean; index: number }) => React.ReactNode;\n  getLabel?: (item: T) => string;\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (id: string) => void;\n  label?: string;\n  /** Fixed card height keeps the stack stable while content changes. */\n  height?: number;\n}\n\nfunction DepartingCard({ motion, reduced, children }: { motion: DeckThrow; reduced: boolean; children: React.ReactNode }) {\n  const ref = React.useRef<HTMLDivElement>(null);\n  React.useLayoutEffect(() => {\n    const el = ref.current;\n    if (!el || reduced) return;\n    const end = -motion.direction * el.offsetWidth * 1.4;\n    const animation = el.animate([\n      { transform: `translateX(${motion.from}px) rotate(${motion.from / 24}deg)`, opacity: 1 },\n      { transform: `translateX(${end}px) rotate(${-motion.direction * 18}deg)`, opacity: 0 },\n    ], { duration: Math.abs(motion.velocity) > 1.5 ? 300 : 420, easing: EASE_OUT, fill: 'forwards' });\n    return () => animation.cancel();\n  }, [motion, reduced]);\n  return <div ref={ref} aria-hidden=\"true\" inert className=\"pointer-events-none absolute inset-0 z-20 overflow-hidden rounded-[22px] border border-border bg-card text-card-foreground\">{children}</div>;\n}\n\n/** Where a card behind the front one sits: lower and smaller by depth, rising as the front card leaves. */\nconst stackTransform = (depth: number, progress: number, fanned: boolean) =>\n  `translateY(${depth * (fanned ? 17 : 12) - progress * 12}px) scale(${1 - depth * 0.055 + progress * 0.055}) rotate(${fanned ? (depth % 2 ? -1 : 1) * depth * 1.3 : 0}deg)`;\n\n/**\n * A tactile stack. Gestures write transforms straight onto the cards, and every card has its own compositor layer,\n * so a drag moves pictures instead of repainting them. React changes only when a card is chosen.\n */\nexport function CardDeck<T extends { id: string }>({ items, renderCard, getLabel = (item) => item.id, value, defaultValue, onValueChange, label = 'Card collection', height = 340, className = '', ...props }: CardDeckProps<T>) {\n  const reduced = useInteractionReducedMotion();\n  const [own, setOwn] = React.useState(defaultValue ?? items[0]?.id);\n  const selected = value ?? own;\n  const index = Math.max(0, items.findIndex((item) => item.id === selected));\n  const current = items[index];\n  const [leaving, setLeaving] = React.useState<DeckThrow | null>(null);\n  const busy = React.useRef(false);\n  const [fanned, setFanned] = React.useState(false);\n  const restoreCardFocus = React.useRef(false);\n\n  const choose = (direction: number, from?: number, velocity = 0) => {\n    if (items.length < 2 || !current || busy.current) return false;\n    const next = items[(index + direction + items.length) % items.length];\n    restoreCardFocus.current = Boolean(document.activeElement?.hasAttribute('data-deck-active'));\n    busy.current = !reduced;\n    const transform = frontRef.current ? getComputedStyle(frontRef.current).transform : 'none';\n    const origin = from ?? (transform === 'none' ? 0 : new DOMMatrixReadOnly(transform).m41);\n    if (!reduced) setLeaving({ id: current.id, direction, from: origin, velocity });\n    if (value === undefined) setOwn(next.id);\n    onValueChange?.(next.id);\n    return true;\n  };\n  const backs = React.useRef(new Map<number, HTMLDivElement>());\n  const fannedRef = React.useRef(false);\n  const onProgress = (progress: number, settle: boolean) => {\n    if (reduced) return;\n    backs.current.forEach((el, depth) => {\n      el.style.transition = settle ? `transform 520ms ${EASE_SETTLE}` : 'none';\n      el.style.transform = stackTransform(depth, progress, fannedRef.current);\n    });\n  };\n  const { rootRef, frontRef, dragging, handlers } = useCardDeckGesture({ id: current?.id, count: items.length, reduced, onThrow: choose, onProgress });\n\n  React.useLayoutEffect(() => {\n    if (restoreCardFocus.current) { frontRef.current?.focus({ preventScroll: true }); restoreCardFocus.current = false; }\n  }, [current?.id, frontRef]);\n\n  React.useLayoutEffect(() => {\n    if (leaving?.id === current?.id) {\n      // A controlled parent may reject a selection. Keep the original card available.\n      if (frontRef.current) frontRef.current.style.transform = '';\n      setLeaving(null); busy.current = false;\n    }\n  }, [leaving, current?.id, frontRef]);\n\n  React.useEffect(() => {\n    const timer = window.setTimeout(() => { setLeaving(null); busy.current = false; }, reduced ? 0 : 430);\n    return () => window.clearTimeout(timer);\n  }, [selected, leaving, reduced]);\n\n  const ordered = current ? Array.from({ length: Math.min(4, items.length) }, (_, depth) => ({ item: items[(index + depth) % items.length], depth })) : [];\n  const departing = leaving && items.find((item) => item.id === leaving.id);\n  const expanded = fanned && !reduced;\n  fannedRef.current = expanded;\n\n  return (\n    <div ref={rootRef} aria-label={label} role=\"region\" className={`w-full text-foreground ${className}`} {...props}>\n      <div className=\"mx-auto max-w-[320px] px-3\">\n        <div className=\"relative isolate\" style={{ height: Math.max(120, height) }} onPointerEnter={() => setFanned(true)} onPointerLeave={() => setFanned(false)}>\n          {ordered.slice().reverse().map(({ item, depth }) => {\n            const active = depth === 0;\n            return (\n              <div key={item.id} ref={active ? frontRef : (el) => { if (el) backs.current.set(depth, el); }} tabIndex={active ? 0 : -1} role=\"group\" aria-roledescription=\"card\"\n                aria-label={active ? `${getLabel(item)}, ${index + 1} of ${items.length}. Use Left and Right to browse.` : undefined}\n                aria-hidden={!active || undefined} inert={!active} data-deck-active={active || undefined}\n                onKeyDown={active ? (event) => {\n                  if (event.target !== event.currentTarget) return;\n                  if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') { event.preventDefault(); choose(event.key === 'ArrowRight' ? 1 : -1); }\n                } : undefined}\n                onFocus={active ? () => setFanned(true) : undefined}\n                onBlur={active ? (event) => { if (!event.currentTarget.contains(event.relatedTarget as Node)) setFanned(false); } : undefined}\n                {...(active ? handlers : {})}\n                className={`absolute inset-0 overflow-hidden rounded-[22px] border border-border bg-card text-card-foreground ${active ? `${dragging ? 'cursor-grabbing' : 'cursor-grab'} ${INTERACTION_FOCUS}` : 'pointer-events-none'}`}\n                style={{\n                  zIndex: 10 - depth, touchAction: active ? 'pan-y' : undefined, willChange: reduced ? undefined : 'transform',\n                  transform: active ? undefined : stackTransform(depth, 0, expanded),\n                  transformOrigin: '50% 100%',\n                  transition: reduced || dragging ? 'none' : `transform 520ms ${EASE_SETTLE}`,\n                }}>\n                {renderCard(item, { active, index: items.indexOf(item) })}\n              </div>\n            );\n          })}\n          {leaving && departing && <DepartingCard key={leaving.id} motion={leaving} reduced={reduced}>{renderCard(departing, { active: false, index: items.indexOf(departing) })}</DepartingCard>}\n        </div>\n      </div>\n      {current && <div className=\"mt-14 flex items-center justify-center gap-5\">\n        <button type=\"button\" aria-label=\"Previous card\" disabled={items.length < 2} onClick={() => choose(-1)} className={`cursor-pointer disabled:cursor-default flex size-9 items-center justify-center rounded-full border border-border transition-colors hover:bg-accent disabled:opacity-30 ${INTERACTION_FOCUS}`}><ArrowLeft className=\"size-3.5\" aria-hidden=\"true\" /></button>\n        <p aria-live=\"polite\" aria-atomic=\"true\" className=\"min-w-12 text-center font-mono text-[11px] tabular-nums text-muted-foreground\"><NumberRoll value={index + 1} format={{ minimumIntegerDigits: 2 }} /> / <NumberRoll value={items.length} format={{ minimumIntegerDigits: 2 }} /></p>\n        <button type=\"button\" aria-label=\"Next card\" disabled={items.length < 2} onClick={() => choose(1)} className={`cursor-pointer disabled:cursor-default flex size-9 items-center justify-center rounded-full border border-border transition-colors hover:bg-accent disabled:opacity-30 ${INTERACTION_FOCUS}`}><ArrowRight className=\"size-3.5\" aria-hidden=\"true\" /></button>\n      </div>}\n    </div>\n  );\n}\n"},{"path":"registry/use-card-deck-gesture.ts","type":"registry:component","content":"// Hairline UI · card-deck · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'use client';\n\nimport * as React from 'react';\nimport { releaseVelocity, samplePointer, springFrames, type PointerSample } from './ui-interaction-motion';\n\nexport type DeckThrow = { id: string; direction: number; from: number; velocity: number };\n\nexport function useCardDeckGesture({ id, count, reduced, onThrow, onProgress }: {\n  id: string | undefined;\n  count: number;\n  reduced: boolean;\n  onThrow: (direction: number, from: number, velocity: number) => boolean;\n  /** How far the front card has travelled toward a throw, 0–1; the stack behind rises with it. */\n  onProgress: (progress: number, settle: boolean) => void;\n}) {\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const frontRef = React.useRef<HTMLDivElement>(null);\n  const animation = React.useRef<Animation | null>(null);\n  const drag = React.useRef<{ pointer: number; startX: number; startY: number; base: number; x: number; width: number; horizontal: boolean; samples: PointerSample[] } | null>(null);\n  const [dragging, setDragging] = React.useState(false);\n\n  React.useEffect(() => () => { animation.current?.cancel(); }, []);\n  React.useLayoutEffect(() => {\n    drag.current = null;\n    setDragging(false);\n    animation.current?.cancel();\n    if (frontRef.current) frontRef.current.style.transform = '';\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [id, reduced]);\n\n  const settle = (from: number, velocity: number) => {\n    const front = frontRef.current;\n    if (!front) return;\n    front.style.transform = '';\n    if (reduced) return;\n    animation.current?.cancel();\n    animation.current = front.animate(springFrames(from, 0, velocity).map(({ value, offset }) => ({\n      transform: `translateX(${value}px) rotate(${value / 24}deg)`, offset,\n    })), { duration: 520, easing: 'linear' });\n  };\n\n  const finish = (event: React.PointerEvent, cancelled: boolean) => {\n    const state = drag.current;\n    if (!state || state.pointer !== event.pointerId) return;\n    drag.current = null;\n    setDragging(false);\n    onProgress(0, true);\n    const velocity = releaseVelocity(state.samples, event.timeStamp);\n    const crossed = Math.abs(state.x) > Math.min(100, state.width * 0.27);\n    const flicked = Math.abs(state.x) > 16 && Math.abs(velocity) > 0.55 && Math.sign(velocity) === Math.sign(state.x);\n    if (!cancelled && state.horizontal && (crossed || flicked)) { if (!onThrow(state.x < 0 ? 1 : -1, state.x, velocity)) settle(state.x, velocity); }\n    else settle(state.x, cancelled ? 0 : velocity);\n  };\n\n  return {\n    rootRef, frontRef, dragging,\n    handlers: {\n      onPointerDown(event: React.PointerEvent<HTMLDivElement>) {\n        if (count < 2 || event.button !== 0 || !event.isPrimary || (event.target as HTMLElement).closest('a,button,input,textarea,select,[data-deck-no-drag]')) return;\n        const transform = getComputedStyle(event.currentTarget).transform;\n        const base = transform === 'none' ? 0 : new DOMMatrixReadOnly(transform).m41;\n        animation.current?.cancel();\n        if (!reduced) event.currentTarget.style.transform = `translateX(${base}px) rotate(${base / 24}deg)`;\n        event.currentTarget.setPointerCapture(event.pointerId);\n        drag.current = { pointer: event.pointerId, startX: event.clientX, startY: event.clientY, base, x: base, width: event.currentTarget.clientWidth, horizontal: false, samples: [{ x: event.clientX, time: event.timeStamp }] };\n      },\n      onPointerMove(event: React.PointerEvent<HTMLDivElement>) {\n        const state = drag.current;\n        if (!state || state.pointer !== event.pointerId) return;\n        const dx = event.clientX - state.startX, dy = event.clientY - state.startY;\n        if (!state.horizontal) {\n          if (Math.abs(dy) > 8 && Math.abs(dy) > Math.abs(dx)) { drag.current = null; settle(state.base, 0); onProgress(0, true); return; }\n          if (Math.abs(dx) < 6) return;\n          state.horizontal = true;\n          setDragging(true);\n        }\n        state.x = state.base + dx;\n        samplePointer(state.samples, event.clientX, event.timeStamp);\n        if (!reduced) {\n          event.currentTarget.style.transform = `translateX(${state.x}px) rotate(${Math.max(-16, Math.min(16, state.x / 24))}deg)`;\n          onProgress(Math.min(1, Math.abs(state.x) / (state.width * 0.65)), false);\n        }\n      },\n      onPointerUp: (event: React.PointerEvent) => finish(event, false),\n      onPointerCancel: (event: React.PointerEvent) => finish(event, true),\n      onLostPointerCapture: (event: React.PointerEvent) => finish(event, true),\n    },\n  };\n}\n"},{"path":"registry/ui-interaction-motion.ts","type":"registry:component","content":"// Hairline UI · card-deck · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'use client';\n\nimport * as React from 'react';\n\nexport const EASE_OUT = 'cubic-bezier(0.16, 1, 0.3, 1)';\nexport const EASE_SETTLE = 'cubic-bezier(0.22, 1.18, 0.36, 1)';\nexport const INTERACTION_FOCUS = 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background';\n\nconst query = '(prefers-reduced-motion: reduce)';\nconst subscribe = (notify: () => void) => {\n  const media = window.matchMedia(query);\n  media.addEventListener('change', notify);\n  return () => media.removeEventListener('change', notify);\n};\nexport const useInteractionReducedMotion = () => React.useSyncExternalStore(subscribe, () => window.matchMedia(query).matches, () => false);\n\nexport function useInteractionActivity<T extends Element>() {\n  const ref = React.useRef<T>(null);\n  const [visible, setVisible] = React.useState(false);\n  const [hidden, setHidden] = React.useState(false);\n  React.useEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));\n    observer.observe(el);\n    const visibility = () => setHidden(document.hidden);\n    visibility(); document.addEventListener('visibilitychange', visibility);\n    return () => { observer.disconnect(); document.removeEventListener('visibilitychange', visibility); };\n  }, []);\n  return { ref, active: visible && !hidden };\n}\n\nexport function interactionIcon(on: boolean, reduced: boolean): React.CSSProperties {\n  return { opacity: on ? 1 : 0, transform: on ? 'none' : 'scale(0.6)', filter: on ? 'blur(0px)' : 'blur(3px)', transition: reduced ? 'none' : `opacity 220ms ${EASE_OUT}, transform 380ms ${EASE_OUT}, filter 220ms ${EASE_OUT}` };\n}\n\n/** Sample recent movement rather than averaging a long drag and losing its release velocity. */\nexport type PointerSample = { x: number; time: number };\nexport function samplePointer(samples: PointerSample[], x: number, time: number) {\n  samples.push({ x, time });\n  while (samples.length > 2 && time - samples[0].time > 100) samples.shift();\n}\nexport function releaseVelocity(samples: PointerSample[], now: number) {\n  if (samples.length < 2 || now - samples[samples.length - 1].time > 100) return 0;\n  const first = samples[0], last = samples[samples.length - 1];\n  return (last.x - first.x) / Math.max(1, last.time - first.time);\n}\n\n/** A damped spring sampled by WAAPI: no React renders or perpetual frame loop. */\nexport function springFrames(from: number, to: number, velocity = 0, duration = 520) {\n  const omega = 20, damping = 0.82, damped = omega * Math.sqrt(1 - damping * damping);\n  const displacement = from - to;\n  const coefficient = (velocity * 1000 + damping * omega * displacement) / damped;\n  return Array.from({ length: 33 }, (_, i) => {\n    const t = (i / 32) * duration / 1000;\n    const value = to + Math.exp(-damping * omega * t) * (displacement * Math.cos(damped * t) + coefficient * Math.sin(damped * t));\n    return { value: i === 32 ? to : value, offset: i / 32 };\n  });\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/card-deck. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}