{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"swipe-deck","type":"registry:ui","title":"Swipe Deck","description":"A stack you decide through.","dependencies":["motion"],"categories":["gesture"],"docs":"https://www.interior.dev/docs/swipe-deck","files":[{"path":"registry/interior/swipe-deck.tsx","type":"registry:ui","target":"components/interior/swipe-deck.tsx","content":"\"use client\";\n\nimport { useCallback, useEffect, useId, useRef, useState } from \"react\";\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n} from \"motion/react\";\n\nconst CELL = { type: \"spring\", stiffness: 520, damping: 34, mass: 0.45 } as const;\nconst DISCLOSE = { type: \"spring\", stiffness: 150, damping: 27, mass: 1 } as const;\nconst CROSSFADE = { type: \"spring\", stiffness: 260, damping: 34, mass: 0.8 } as const;\nconst LEAVE = [0.4, 0, 1, 1] as const;\n\nexport type SwipeChoice = \"left\" | \"right\";\n\nexport type SwipeIntent = { dir: -1 | 0 | 1; step: number };\n\nexport type SwipeDeckFlow = { dir: -1 | 1; kind: \"decide\" | \"undo\" };\n\nconst BLANK: SwipeIntent = { dir: 0, step: 0 };\n\nconst spent = (out: boolean) => (out ? \"opacity-0\" : \"\");\n\nexport type UseSwipeDeckOptions = {\n  count: number;\n  threshold?: number;\n  steps?: number;\n  flick?: number;\n  onDecide?: (index: number, choice: SwipeChoice) => void;\n  onUndo?: (index: number) => void;\n  disabled?: boolean;\n};\n\nexport function useSwipeDeck({\n  count,\n  threshold = 92,\n  steps = 6,\n  flick = 520,\n  onDecide,\n  onUndo,\n  disabled = false,\n}: UseSwipeDeckOptions) {\n  const total = Math.max(0, Math.floor(count));\n  const grain = Math.max(1, Math.floor(steps));\n  const reach = Math.max(1, threshold);\n\n  const [decisions, setDecisions] = useState<SwipeChoice[]>([]);\n  const [flow, setFlow] = useState<SwipeDeckFlow>({ dir: 1, kind: \"decide\" });\n  const [intent, setIntent] = useState<SwipeIntent>(BLANK);\n\n  const index = Math.min(decisions.length, total);\n\n  const len = useRef(decisions.length);\n  len.current = decisions.length;\n  const size = useRef(total);\n  size.current = total;\n  const made = useRef(decisions);\n  made.current = decisions;\n\n  const decided = useRef(onDecide);\n  decided.current = onDecide;\n  const reverted = useRef(onUndo);\n  reverted.current = onUndo;\n\n  const clear = useCallback(() => {\n    setIntent((prev) => (prev.step === 0 && prev.dir === 0 ? prev : BLANK));\n  }, []);\n\n  const decide = useCallback(\n    (choice: SwipeChoice) => {\n      if (disabled) return;\n      const at = len.current;\n      if (at >= size.current) return;\n      len.current = at + 1;\n      setDecisions((prev) => [...prev, choice]);\n      setFlow({ dir: choice === \"right\" ? 1 : -1, kind: \"decide\" });\n      setIntent(BLANK);\n      decided.current?.(at, choice);\n    },\n    [disabled],\n  );\n\n  const undo = useCallback(() => {\n    if (disabled) return;\n    const at = len.current;\n    if (at === 0) return;\n    const last = made.current[at - 1];\n    len.current = at - 1;\n    setDecisions((prev) => prev.slice(0, prev.length - 1));\n    setFlow({ dir: last === \"right\" ? 1 : -1, kind: \"undo\" });\n    setIntent(BLANK);\n    reverted.current?.(at - 1);\n  }, [disabled]);\n\n  const report = useCallback(\n    (dx: number) => {\n      const step = Math.min(grain, Math.round((Math.abs(dx) / reach) * grain));\n      const dir: -1 | 0 | 1 = step === 0 ? 0 : dx > 0 ? 1 : -1;\n      setIntent((prev) =>\n        prev.dir === dir && prev.step === step ? prev : { dir, step },\n      );\n    },\n    [grain, reach],\n  );\n\n  const release = useCallback(\n    (dx: number, vx: number) => {\n      const far = Math.abs(dx) >= reach;\n      const fast = Math.abs(vx) >= flick && Math.abs(dx) >= reach * 0.35;\n      if (!far && !fast) {\n        clear();\n        return;\n      }\n      decide((far ? dx : vx) > 0 ? \"right\" : \"left\");\n    },\n    [reach, flick, clear, decide],\n  );\n\n  const onKeyDown = useCallback(\n    (event: React.KeyboardEvent) => {\n      if (event.target !== event.currentTarget) return;\n      if (event.key === \"ArrowLeft\") {\n        event.preventDefault();\n        decide(\"left\");\n      } else if (event.key === \"ArrowRight\") {\n        event.preventDefault();\n        decide(\"right\");\n      } else if (event.key === \"Backspace\" || event.key === \"Delete\") {\n        event.preventDefault();\n        undo();\n      } else if (event.key === \"Escape\") {\n        clear();\n      }\n    },\n    [decide, undo, clear],\n  );\n\n  useEffect(() => {\n    const bail = () => clear();\n    const hidden = () => document.hidden && clear();\n    window.addEventListener(\"blur\", bail);\n    document.addEventListener(\"visibilitychange\", hidden);\n    return () => {\n      window.removeEventListener(\"blur\", bail);\n      document.removeEventListener(\"visibilitychange\", hidden);\n    };\n  }, [clear]);\n\n  return {\n    index,\n    count: total,\n    remaining: total - index,\n    done: index >= total,\n    decisions,\n    flow,\n    intent,\n    steps: grain,\n    threshold: reach,\n    armed: intent.step >= grain,\n    canUndo: decisions.length > 0,\n    decide,\n    undo,\n    clear,\n    report,\n    release,\n    deckProps: {\n      role: \"group\" as const,\n      \"aria-roledescription\": \"card deck\",\n      tabIndex: 0,\n      onKeyDown,\n    },\n  };\n}\n\nexport type UseSwipeDeckResult = ReturnType<typeof useSwipeDeck>;\n\nconst ICON_LEFT = (\n  <svg width=\"12\" height=\"12\" viewBox=\"0 0 256 256\" fill=\"none\" aria-hidden=\"true\">\n    <line\n      x1=\"200\"\n      y1=\"56\"\n      x2=\"56\"\n      y2=\"200\"\n      stroke=\"currentColor\"\n      strokeWidth=\"16\"\n      strokeLinecap=\"round\"\n    />\n    <line\n      x1=\"200\"\n      y1=\"200\"\n      x2=\"56\"\n      y2=\"56\"\n      stroke=\"currentColor\"\n      strokeWidth=\"16\"\n      strokeLinecap=\"round\"\n    />\n  </svg>\n);\n\nconst ICON_RIGHT = (\n  <svg width=\"12\" height=\"12\" viewBox=\"0 0 256 256\" fill=\"none\" aria-hidden=\"true\">\n    <polyline\n      points=\"216 72 104 184 48 128\"\n      stroke=\"currentColor\"\n      strokeWidth=\"16\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n    />\n  </svg>\n);\n\nconst ICON_UNDO = (\n  <svg width=\"12\" height=\"12\" viewBox=\"0 0 256 256\" fill=\"none\" aria-hidden=\"true\">\n    <polyline\n      points=\"72 104 24 104 24 56\"\n      stroke=\"currentColor\"\n      strokeWidth=\"16\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n    />\n    <path\n      d=\"M67.6,192.1a88,88,0,1,0,0-128.2L24,104\"\n      stroke=\"currentColor\"\n      strokeWidth=\"16\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n    />\n  </svg>\n);\n\ntype DeckCardProps = {\n  depth: number;\n  height: number;\n  entryX: number;\n  active: boolean;\n  reduced: boolean;\n  label: string;\n  leftLabel: string;\n  rightLabel: string;\n  intent: SwipeIntent;\n  steps: number;\n  onMove: (dx: number) => void;\n  onRelease: (dx: number, vx: number) => void;\n  children: React.ReactNode;\n};\n\nfunction DeckCard({\n  depth,\n  height,\n  entryX,\n  active,\n  reduced,\n  label,\n  leftLabel,\n  rightLabel,\n  intent,\n  steps,\n  onMove,\n  onRelease,\n  children,\n}: DeckCardProps) {\n  const x = useMotionValue(entryX);\n  const rotate = useTransform(x, [-200, 0, 200], [-8, 0, 8], { clamp: false });\n  const fade = useTransform(x, [-340, -150, 0, 150, 340], [0, 1, 1, 1, 0]);\n\n  const skip = useRef(reduced);\n  skip.current = reduced;\n\n  useEffect(() => {\n    if (x.get() === 0) return;\n    const controls = animate(x, 0, skip.current ? { duration: 0 } : DISCLOSE);\n    return () => controls.stop();\n  }, [x, entryX]);\n\n  const commit = active ? 0 : depth === 1 ? intent.step / steps : 0;\n  const y = depth * 10 - commit * 10;\n  const scale = 1 - depth * 0.045 + commit * 0.045;\n\n  const badge = (side: -1 | 1, text: string, place: string) => {\n    const on = intent.dir === side;\n    return (\n      <motion.span\n        aria-hidden\n        initial={false}\n        animate={{\n          opacity: on ? intent.step / steps : 0,\n          scale: on ? 1 : 0.94,\n        }}\n        transition={reduced ? { duration: 0 } : CELL}\n        className={`pointer-events-none absolute top-3 whitespace-nowrap rounded-[6px] border bg-white px-2 py-1 text-[10.5px] font-semibold uppercase tracking-[0.08em] transition-colors duration-150 dark:bg-[#1D1D1A] ${\n          on && intent.step >= steps\n            ? \"border-[#4568FF] text-[#4568FF] dark:border-[#93B0FF] dark:text-[#93B0FF]\"\n            : \"border-stone-300 text-stone-700 dark:border-white/20 dark:text-stone-200\"\n        } ${place}`}\n      >\n        {text}\n      </motion.span>\n    );\n  };\n\n  return (\n    <motion.div\n      role=\"group\"\n      aria-label={label}\n      aria-hidden={!active}\n      inert={!active}\n      variants={{\n        exit: (dir: number) => ({\n          x: dir * 560,\n          zIndex: 12,\n          borderColor: \"rgba(0,0,0,0)\",\n          transition: reduced\n            ? { duration: 0 }\n            : {\n                x: { duration: 0.3, ease: LEAVE },\n                borderColor: { duration: 0.1, ease: \"linear\" },\n              },\n        }),\n      }}\n      initial={{ y, scale }}\n      animate={{ y, scale }}\n      exit=\"exit\"\n      transition={\n        reduced\n          ? { duration: 0 }\n          : active\n            ? { ...CROSSFADE, delay: 0.1 }\n            : CROSSFADE\n      }\n      drag={active ? \"x\" : false}\n      dragDirectionLock\n      dragMomentum={false}\n      dragElastic={1}\n      dragConstraints={{ left: 0, right: 0 }}\n      dragTransition={{ bounceStiffness: 260, bounceDamping: 34 }}\n      whileDrag={reduced ? undefined : { scale: 1.03 }}\n      onDrag={(_event, info) => onMove(info.offset.x)}\n      onDragEnd={(_event, info) => onRelease(info.offset.x, info.velocity.x)}\n      style={{\n        x,\n        rotate,\n        opacity: fade,\n        height,\n        zIndex: 10 - depth,\n        transformOrigin: \"50% 100%\",\n        touchAction: \"pan-y\",\n      }}\n      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] ${\n        active\n          ? \"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)]\"\n          : \"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)]\"\n      }`}\n    >\n      {children}\n      {active ? badge(-1, leftLabel, \"left-3\") : null}\n      {active ? badge(1, rightLabel, \"right-3\") : null}\n    </motion.div>\n  );\n}\n\nexport type SwipeDeckProps<T> = {\n  items: readonly T[];\n  itemKey: (item: T) => string;\n  itemLabel: (item: T) => string;\n  children: (item: T) => React.ReactNode;\n  onDecide?: (item: T, choice: SwipeChoice) => void;\n  onUndo?: (item: T) => void;\n  label?: string;\n  leftLabel?: string;\n  rightLabel?: string;\n  undoLabel?: string;\n  emptyLabel?: string;\n  height?: number;\n  threshold?: number;\n  steps?: number;\n  peek?: number;\n  className?: string;\n};\n\nexport function SwipeDeck<T>({\n  items,\n  itemKey,\n  itemLabel,\n  children,\n  onDecide,\n  onUndo,\n  label = \"Card deck\",\n  leftLabel = \"Skip\",\n  rightLabel = \"Keep\",\n  undoLabel = \"Undo\",\n  emptyLabel = \"Deck cleared\",\n  height = 180,\n  threshold = 92,\n  steps = 6,\n  peek = 3,\n  className = \"\",\n}: SwipeDeckProps<T>) {\n  const hintId = useId();\n  const reduced = useReducedMotion() === true;\n\n  const deck = useSwipeDeck({\n    count: items.length,\n    threshold,\n    steps,\n    onDecide: (at, choice) => {\n      const item = items[at];\n      if (item !== undefined) onDecide?.(item, choice);\n    },\n    onUndo: (at) => {\n      const item = items[at];\n      if (item !== undefined) onUndo?.(item);\n    },\n  });\n\n  const stack = items.slice(deck.index, deck.index + Math.max(1, peek));\n  const current = items[deck.index];\n\n  const control =\n    \"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]\";\n\n  return (\n    <div className={`w-full ${className}`}>\n      <div\n        aria-label={label}\n        aria-describedby={hintId}\n        style={{ height: height + 26 }}\n        className=\"relative w-full overflow-hidden rounded-[14px] outline-none focus-visible:shadow-[0_0_0_1px_#4568FF] dark:focus-visible:shadow-[0_0_0_1px_#93B0FF]\"\n        {...deck.deckProps}\n      >\n        <div className=\"absolute inset-0 overflow-hidden [mask-image:linear-gradient(to_right,transparent,black_20px,black_calc(100%-20px),transparent)]\">\n          <motion.div\n            aria-hidden={!deck.done}\n            initial={false}\n            animate={{ opacity: deck.done ? 1 : 0 }}\n            transition={reduced ? { duration: 0 } : CROSSFADE}\n            style={{ height }}\n            className=\"absolute inset-x-5 top-0 z-0 grid place-items-center rounded-[14px] bg-stone-100/70 px-4 text-center text-[12.5px] text-stone-500 shadow-[inset_0_1px_2px_rgba(28,25,23,0.07)] dark:bg-[#252522] dark:text-stone-400 dark:shadow-[inset_0_1px_2px_rgba(0,0,0,0.45)]\"\n          >\n            {emptyLabel}\n          </motion.div>\n          <AnimatePresence initial={false} custom={deck.flow.dir}>\n            {stack.map((item, depth) => (\n              <DeckCard\n              key={itemKey(item)}\n              depth={depth}\n              height={height}\n              entryX={\n                depth === 0 && deck.flow.kind === \"undo\" ? deck.flow.dir * 560 : 0\n              }\n              active={depth === 0}\n              reduced={reduced}\n              label={itemLabel(item)}\n              leftLabel={leftLabel}\n              rightLabel={rightLabel}\n              intent={deck.intent}\n              steps={deck.steps}\n              onMove={deck.report}\n              onRelease={deck.release}\n            >\n                {children(item)}\n              </DeckCard>\n            ))}\n          </AnimatePresence>\n        </div>\n      </div>\n      <div className=\"mt-3 grid h-8 grid-cols-[1fr_auto_1fr] items-center gap-3\">\n        <button\n          type=\"button\"\n          onClick={() => deck.decide(\"left\")}\n          inert={deck.done}\n          className={`${control} justify-self-start ${spent(deck.done)}`}\n        >\n          {ICON_LEFT}\n          <span>{leftLabel}</span>\n        </button>\n        <span className=\"flex items-center gap-2 font-mono text-[10.5px] tabular-nums text-stone-500 dark:text-stone-400\">\n          <span aria-hidden className=\"inline-grid justify-items-end\">\n            <span className=\"invisible col-start-1 row-start-1\">\n              {items.length}\n            </span>\n            <span className=\"col-start-1 row-start-1\">{deck.remaining}</span>\n          </span>\n          <span aria-hidden>left</span>\n          <button\n            type=\"button\"\n            onClick={deck.undo}\n            inert={!deck.canUndo}\n            className={`inline-flex items-center gap-1 rounded-[5px] px-1 py-0.5 text-stone-700 outline-none transition-[background-color,box-shadow,opacity] duration-150 hover:bg-stone-100 focus-visible:bg-[#4568FF]/[0.06] focus-visible:shadow-[inset_0_0_0_1px_#4568FF] dark:text-stone-200 dark:hover:bg-white/10 dark:focus-visible:bg-[#93B0FF]/[0.1] dark:focus-visible:shadow-[inset_0_0_0_1px_#93B0FF] ${spent(\n              !deck.canUndo,\n            )}`}\n          >\n            {ICON_UNDO}\n            <span>{undoLabel}</span>\n          </button>\n        </span>\n        <button\n          type=\"button\"\n          onClick={() => deck.decide(\"right\")}\n          inert={deck.done}\n          className={`${control} justify-self-end ${spent(deck.done)}`}\n        >\n          <span>{rightLabel}</span>\n          {ICON_RIGHT}\n        </button>\n      </div>\n      <p aria-live=\"polite\" aria-atomic className=\"sr-only\">\n        {deck.done || current === undefined\n          ? emptyLabel\n          : `${itemLabel(current)}. Card ${deck.index + 1} of ${items.length}.`}\n      </p>\n      <span id={hintId} className=\"sr-only\">\n        Left and right arrow keys decide the top card. Backspace brings the last\n        one back.\n      </span>\n    </div>\n  );\n}\n"}]}