{
  "$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://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/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 {\n  AnimatePresence,\n  animate,\n  motion,\n  useMotionValue,\n  useReducedMotion,\n  useSpring,\n  useTransform,\n} from \"motion/react\";\nimport type { MotionValue } from \"motion/react\";\nimport { Check, RotateCcw, X } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\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<T> {\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\n/** Keyboard focus on the stage outlines the top card (or the empty state) instead of the whole stage. */\nconst stageFocus =\n  \"group-focus-visible/stage:[outline:2px_solid_var(--focus-outline,color-mix(in_oklab,var(--accent-ink)_72%,transparent))] group-focus-visible/stage:outline-offset-3\";\nconst control =\n  \"inline-flex min-h-control-md cursor-pointer items-center justify-center gap-2 rounded-button border border-line bg-surface px-4 text-sm leading-body font-medium whitespace-nowrap text-ink transition-[background-color,border-color,color,opacity] duration-fast ease-standard [-webkit-tap-highlight-color:transparent] hover:not-aria-disabled:bg-hover active:not-aria-disabled:bg-hover aria-disabled:cursor-default aria-disabled:opacity-[.42] max-[26rem]:px-3 motion-reduce:transition-none [&_svg]:flex-none\";\n/** The approving action follows the selected accent. On the narrowest screens Undo keeps its icon and its accessible name, so all three controls stay on one row. */\nconst tones = {\n  primary:\n    \"border-accent bg-accent text-on-accent hover:not-aria-disabled:border-accent-ink hover:not-aria-disabled:bg-accent-ink active:not-aria-disabled:border-accent-ink active:not-aria-disabled:bg-accent-ink\",\n  quiet:\n    \"border-transparent bg-transparent text-ink-2 hover:not-aria-disabled:text-ink max-[22.5rem]:w-control-md max-[22.5rem]:p-0\",\n} as const;\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 = 0.05;\n/** How far the cards behind rise while the top card is dragged away, in stack steps. */\nconst LIFT = 0.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 = 0.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\n    ? raw\n    : Math.sign(raw) * (FREE_Y + (1 - 1 / (((distance - FREE_Y) * 0.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) / 0.55);\n};\nconst clampUnit = (value: number) => Math.min(1, Math.max(0, value));\nconst throwSpring = { type: \"spring\", visualDuration: 0.5, bounce: 0 } as const;\nconst fadeOut = { duration: 0.22, delay: 0.12, ease: [...motionTokens.ease.standard] } as const;\nconst reducedFade = { duration: 0.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],\n    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<number>;\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({\n  id,\n  depth,\n  decision,\n  returnFrom,\n  interactive,\n  zIndex,\n  label,\n  labels,\n  lift,\n  reduced,\n  fling,\n  onRelease,\n  onThrown,\n  onGone,\n  children,\n}: CardProps) {\n  const cardRef = useRef<HTMLDivElement>(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 = 0, sign = 0]: 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 = () => {\n      if (!homing.current) lift.set(clampUnit(Math.hypot(x.get(), y.get() * 0.5) / LIFT_DISTANCE));\n    };\n    const offX = x.on(\"change\", update),\n      offY = y.on(\"change\", update);\n    return () => {\n      offX();\n      offY();\n    };\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(),\n        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,\n        uy = velocity.y;\n      if (speed < 200 || Math.sign(ux) !== direction) {\n        ux = Math.abs(cx) > 8 ? cx : direction;\n        uy = Math.abs(cx) > 8 ? cy * 0.5 : -0.12;\n      }\n      const length = Math.hypot(ux, uy) || 1;\n      ux /= length;\n      uy /= length;\n      if (Math.abs(ux) < 0.45) {\n        ux = direction * 0.45;\n        uy = Math.sign(uy || -1) * Math.sqrt(1 - 0.45 * 0.45);\n      }\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) {\n        animate(opacity, 0, { ...reducedFade, onComplete: () => onGone(id) });\n        return;\n      }\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) {\n      x.jump(0);\n      y.jump(0);\n      animate(opacity, 1, { ...reducedFade, delay });\n      return;\n    }\n    homing.current = true;\n    animate(x, 0, {\n      ...motionTokens.spring.smooth,\n      delay,\n      onComplete: () => {\n        homing.current = false;\n      },\n    });\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<HTMLDivElement>) {\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']\"))\n      return;\n    x.stop();\n    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 = {\n      ox: event.clientX - x.get(),\n      oy: event.clientY - unresistY(y.get()),\n      samples: [{ t: event.timeStamp, x: event.clientX, y: event.clientY }],\n    };\n    event.currentTarget.setPointerCapture(event.pointerId);\n    event.currentTarget.setAttribute(\"data-dragging\", \"\");\n  }\n  function move(event: ReactPointerEvent<HTMLDivElement>) {\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<HTMLDivElement>) {\n    const state = drag.current;\n    drag.current = null;\n    event.currentTarget.removeAttribute(\"data-dragging\");\n    if (event.currentTarget.hasPointerCapture(event.pointerId))\n      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) {\n      x.jump(0);\n      y.jump(0);\n      return;\n    }\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  // Cards returning to the back of the stack carry no verdict.\n  const stamp = cn(\n    \"pointer-events-none absolute top-4 inline-flex h-8 items-center gap-1.5 rounded-full border border-line bg-surface pr-3 pl-2.5 text-sm/[inherit] font-medium whitespace-nowrap text-ink\",\n    !interactive && !flying && \"invisible\",\n  );\n  return (\n    <motion.div\n      className=\"pointer-events-none absolute inset-x-0 top-0 h-(--card-height) origin-bottom\"\n      style={{ zIndex, scale, y: lower, opacity: presence }}\n      aria-hidden={interactive ? undefined : true}\n      inert={!interactive}\n    >\n      <motion.div\n        ref={cardRef}\n        // Resting cards sit flat on their borders; a card in the hand or in flight floats and casts a shadow.\n        className={cn(\n          \"pointer-events-auto absolute inset-0 touch-pan-y rounded-surface select-none [-webkit-tap-highlight-color:transparent] before:pointer-events-none before:absolute before:inset-0 before:rounded-[inherit] before:opacity-0 before:shadow-floating before:transition-opacity before:duration-fast before:ease-standard before:content-[''] data-dragging:cursor-grabbing data-dragging:before:opacity-100 data-flying:before:opacity-100 motion-reduce:before:transition-none\",\n          interactive ? cn(\"cursor-grab\", stageFocus) : \"pointer-events-none\",\n        )}\n        style={{ x, y, rotate, opacity }}\n        data-top={interactive ? \"\" : undefined}\n        data-flying={flying ? \"\" : undefined}\n        role={interactive ? \"group\" : undefined}\n        aria-label={interactive ? label : undefined}\n        onPointerDown={down}\n        onPointerMove={move}\n        onPointerUp={up}\n        onPointerCancel={up}\n      >\n        <div className=\"relative h-full overflow-hidden rounded-[inherit] border border-line bg-surface text-ink\">\n          {children}\n          <motion.span\n            className=\"pointer-events-none absolute inset-0 bg-[oklch(0%_0_0/.035)] dark:bg-[oklch(0%_0_0/.22)]\"\n            style={{ opacity: shade }}\n            aria-hidden=\"true\"\n          />\n        </div>\n        <motion.span\n          className={cn(\n            stamp,\n            \"left-4 border-[color-mix(in_oklab,var(--green)_32%,var(--line))] bg-[color-mix(in_oklab,var(--green)_9%,var(--surface))] [&_svg]:text-green\",\n          )}\n          data-side=\"right\"\n          style={{ opacity: keep }}\n          aria-hidden=\"true\"\n        >\n          <Check size={16} strokeWidth={2} />\n          {labels.right}\n        </motion.span>\n        <motion.span\n          className={cn(stamp, \"right-4 [&_svg]:text-ink-2\")}\n          data-side=\"left\"\n          style={{ opacity: pass }}\n          aria-hidden=\"true\"\n        >\n          <X size={16} strokeWidth={2} />\n          {labels.left}\n        </motion.span>\n      </motion.div>\n    </motion.div>\n  );\n}\n\nfunction Control({\n  children,\n  onClick,\n  disabled,\n  reduced,\n  tone,\n}: {\n  children: ReactNode;\n  onClick: () => void;\n  disabled: boolean;\n  reduced: boolean;\n  tone?: \"primary\" | \"quiet\";\n}) {\n  // aria-disabled keeps focus on the button when the stack runs out, instead of dropping it to the page.\n  return (\n    <motion.button\n      type=\"button\"\n      className={cn(control, tone && tones[tone])}\n      data-tone={tone}\n      aria-disabled={disabled || undefined}\n      onClick={() => {\n        if (!disabled) onClick();\n      }}\n      whileTap={{ scale: reduced || disabled ? 1 : 0.97 }}\n      transition={motionTokens.spring.snappy}\n    >\n      {children}\n    </motion.button>\n  );\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 }) {\n  return <>{render(onReset)}</>;\n}\n\nexport function CardStack<T>({\n  items,\n  getKey,\n  getLabel,\n  renderCard,\n  onDecide,\n  onUndo,\n  onReset,\n  labels = { left: \"Pass\", right: \"Keep\" },\n  outcomes,\n  label = \"Cards\",\n  renderEmpty,\n  className,\n}: CardStackProps<T>) {\n  const hydrated = useSyncExternalStore(\n    noop,\n    () => true,\n    () => false,\n  );\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<string[]>([]);\n  const [returning, setReturning] = useState<Record<string, Return>>({});\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<Record<string, Exit>>({});\n  const flings = useRef(new Map<string, Point>());\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(\n      `${getLabel(top)}${outcomes ? ` ${outcomes[decision]}` : `: ${labels[decision]}`}. ${left ? `${left} left.` : \"All cards reviewed.\"}`,\n    );\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<string, Return> = {};\n    items.slice(0, count).forEach((item, index) => {\n      const key = getKey(item),\n        exit = exits.current[key];\n      if (exit && index < position) back[key] = { ...exit, delay: (count - 1 - index) * 0.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 =\n      Math.abs(offset.x) > Math.min(width * 0.35, 140) &&\n      !(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<HTMLDivElement>) {\n    if (event.target !== event.currentTarget) return;\n    if (event.key === \"ArrowLeft\" || event.key === \"ArrowRight\") {\n      event.preventDefault();\n      decide(event.key === \"ArrowRight\" ? \"right\" : \"left\");\n    } else if (event.key === \"Backspace\" || ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === \"z\")) {\n      event.preventDefault();\n      undo();\n    }\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    <div\n      className={cn(\n        \"grid w-full justify-items-center gap-5 font-body tracking-body [--card-height:19rem] [--stack-peek:24px] max-[26rem]:gap-4 max-[26rem]:[--card-height:17.5rem]\",\n        className,\n      )}\n    >\n      {/* oxlint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- the stack is one focusable widget decided with the arrow keys; <fieldset> would add form semantics */}\n      <div\n        className=\"group/stage relative isolate h-[calc(var(--card-height)_+_var(--stack-peek))] w-[min(100%,20rem)] outline-none focus-visible:[--focus-outline-width:0px]\"\n        role=\"group\"\n        aria-roledescription=\"card stack\"\n        aria-label={label}\n        aria-describedby={hintId}\n        // oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- the stack is one focusable widget decided with the arrow keys; <fieldset> would add form semantics\n        tabIndex={0}\n        onKeyDown={key}\n      >\n        {rendered.map((item) => {\n          const id = getKey(item),\n            index = items.indexOf(item),\n            decision = decided.get(id) ?? null;\n          const depth = decision ? 0 : index - position;\n          return (\n            <StackCard\n              key={id}\n              id={id}\n              depth={depth}\n              decision={decision}\n              returnFrom={returning[id]}\n              interactive={!decision && depth === 0}\n              zIndex={decision ? 20 + flying.indexOf(id) : VISIBLE - depth}\n              label={`${getLabel(item)}, ${index + 1} of ${items.length}`}\n              labels={labels}\n              lift={lift}\n              reduced={reduced}\n              fling={(cardKey) => {\n                const velocity = flings.current.get(cardKey);\n                flings.current.delete(cardKey);\n                return velocity;\n              }}\n              onRelease={release}\n              onThrown={(cardKey, exit) => {\n                exits.current[cardKey] = exit;\n              }}\n              onGone={(cardKey) => setFlying((current) => current.filter((entry) => entry !== cardKey))}\n            >\n              {renderCard(item)}\n            </StackCard>\n          );\n        })}\n        <AnimatePresence initial={false}>\n          {empty && (\n            <motion.div\n              key=\"empty\"\n              className={cn(\n                \"absolute inset-x-0 top-0 grid h-(--card-height) place-content-center justify-items-center gap-3 rounded-surface border border-line bg-surface p-6 text-center\",\n                stageFocus,\n              )}\n              initial={reduced ? { opacity: 0 } : { opacity: 0, y: 8, filter: `blur(${motionTokens.blur.soft}px)` }}\n              animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n              exit={{ opacity: 0, transition: { duration: motionTokens.duration.instant } }}\n              transition={\n                reduced ? reducedFade : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }\n              }\n            >\n              {renderEmpty ? (\n                <EmptySlot render={renderEmpty} onReset={reset} />\n              ) : (\n                <>\n                  <p className=\"m-0 text-base/[inherit] font-medium\">All cards reviewed</p>\n                  <button type=\"button\" className={control} onClick={reset}>\n                    <RotateCcw size={16} strokeWidth={1.75} aria-hidden=\"true\" />\n                    Start over\n                  </button>\n                </>\n              )}\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </div>\n      <div className=\"flex flex-wrap justify-center gap-2 max-[26rem]:flex-nowrap\">\n        <Control onClick={() => decide(\"left\")} disabled={!top} reduced={reduced}>\n          <X size={16} strokeWidth={1.75} aria-hidden=\"true\" />\n          {labels.left}\n        </Control>\n        <Control onClick={undo} disabled={!history.length} reduced={reduced} tone=\"quiet\">\n          <RotateCcw size={16} strokeWidth={1.75} aria-hidden=\"true\" />\n          <span className=\"max-[22.5rem]:sr-only\">Undo</span>\n        </Control>\n        <Control onClick={() => decide(\"right\")} disabled={!top} reduced={reduced} tone=\"primary\">\n          <Check size={16} strokeWidth={1.75} aria-hidden=\"true\" />\n          {labels.right}\n        </Control>\n      </div>\n      <p id={hintId} className=\"sr-only\">\n        Drag the top card, or use the left and right arrow keys to {labels.left.toLowerCase()} or{\" \"}\n        {labels.right.toLowerCase()} it. Backspace undoes the last decision.\n      </p>\n      <p className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n        {message}\n      </p>\n    </div>\n  );\n}\n\nexport default CardStack;\n",
      "type": "registry:component",
      "target": "@components/iris/card-stack/card-stack.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/card-stack",
  "meta": {
    "kind": "component",
    "group": "Data display",
    "export": "CardStack",
    "tags": [
      "gesture",
      "motion",
      "triage"
    ],
    "docs": "https://irisui.dev/components/card-stack"
  }
}
