{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "passcode",
  "type": "registry:ui",
  "title": "Passcode",
  "description": "A segmented one-time-code input: a row of single-character cells backed by real inputs. Typing seats each digit with a soft pop and advances focus, Backspace retreats, arrow keys move, and paste or SMS autofill distributes across cells. The focused empty cell shows an animated caret in an accent ring; a complete code flashes success and fires onComplete. Fully keyboard and screen-reader accessible with a mobile numeric keypad.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://crucibleui.com/r/use-reduced-motion.json"
  ],
  "files": [
    {
      "path": "registry/default/inputs/passcode/passcode.tsx",
      "type": "registry:ui",
      "target": "components/crucible/passcode.tsx",
      "content": "\"use client\";\n\nimport { AnimatePresence, motion } from \"motion/react\";\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\n\nexport interface PasscodeProps {\n  /** Controlled value — a string of up to `length` characters. Omit for uncontrolled use. */\n  value?: string;\n  /** Initial value when uncontrolled. @default \"\" */\n  defaultValue?: string;\n  /** Number of single-character cells. @default 6 */\n  length?: number;\n  /**\n   * Accepted characters. `\"numeric\"` allows 0–9 (mobile number pad, one-time-code\n   * autofill); `\"alphanumeric\"` allows 0–9 and A–Z. @default \"numeric\"\n   */\n  mode?: \"numeric\" | \"alphanumeric\";\n  /** Hide entered characters behind dots (like a PIN). @default false */\n  masked?: boolean;\n  /** Fires on every change with the concatenated value. */\n  onChange?: (value: string) => void;\n  /** Fires once every cell is filled, with the complete code. */\n  onComplete?: (value: string) => void;\n  /**\n   * `[accent, success]`. Accent lights the focus ring + caret; success flashes\n   * the row when the code completes. @default indigo + emerald\n   */\n  colors?: [string, string] | string[];\n  /** Animation-speed multiplier for the pop, caret blink and success flash. @default 1 */\n  speed?: number;\n  /** Freeze the caret blink and skip the success flash (still seats instantly). @default false */\n  paused?: boolean;\n  /** Disable all cells. @default false */\n  disabled?: boolean;\n  /** Focus the first cell on mount. @default false */\n  autoFocus?: boolean;\n  /** Submit name — emits a hidden input carrying the concatenated value. */\n  name?: string;\n  /**\n   * Cell scale. `\"md\"` is the roomy standalone size; `\"sm\"` is a tighter\n   * cell for auth cards, dialogs and narrow containers. @default \"md\"\n   */\n  size?: \"sm\" | \"md\";\n  /** Accessible name for the group of cells. @default \"Verification code\" */\n  \"aria-label\"?: string;\n  className?: string;\n}\n\nconst SIZES = {\n  md: {\n    gap: \"gap-2 sm:gap-3\",\n    cell: \"h-14 w-11 sm:w-12\",\n    radius: \"rounded-xl\",\n    glyph: \"text-2xl\",\n    dot: \"h-2.5 w-2.5\",\n    caret: \"h-6\",\n  },\n  sm: {\n    gap: \"gap-1.5 sm:gap-2\",\n    cell: \"h-12 w-10\",\n    radius: \"rounded-[10px]\",\n    glyph: \"text-xl\",\n    dot: \"h-2 w-2\",\n    caret: \"h-5\",\n  },\n} as const;\n\n/** Indigo accent, emerald success — neutral-elegant, cool. */\nconst DEFAULT_COLORS: [string, string] = [\"#818cf8\", \"#34d399\"];\n\nfunction sanitize(input: string, mode: \"numeric\" | \"alphanumeric\"): string {\n  return mode === \"numeric\"\n    ? input.replace(/[^0-9]/g, \"\")\n    : input.replace(/[^0-9a-zA-Z]/g, \"\");\n}\n\n/**\n * Passcode — a row of `length` segmented single-character cells backed by real\n * inputs. Typing advances focus and seats each digit with a soft pop; Backspace\n * retreats, arrow/Home/End keys move, and a paste (or SMS one-time-code autofill)\n * distributes across the cells. The focused empty cell shows an animated caret\n * inside an accent focus ring; the moment every cell is filled the row flashes\n * a success hue and `onComplete` fires. Fully keyboard operable, every cell\n * carries its own aria-label, and mobile gets a numeric keypad. Reduced motion\n * drops the pop, caret blink and flash — cells seat instantly and success shows\n * as a static colour change.\n */\nexport function Passcode({\n  value,\n  defaultValue = \"\",\n  length = 6,\n  mode = \"numeric\",\n  masked = false,\n  onChange,\n  onComplete,\n  colors = DEFAULT_COLORS,\n  speed = 1,\n  paused = false,\n  disabled = false,\n  autoFocus = false,\n  name,\n  size = \"md\",\n  \"aria-label\": ariaLabel = \"Verification code\",\n  className,\n}: PasscodeProps) {\n  const sz = SIZES[size] ?? SIZES.md;\n  const reducedMotion = useReducedMotion();\n  const animate = !reducedMotion && !paused;\n  const count = Math.max(1, Math.floor(length));\n  const spd = Math.max(0.1, speed);\n\n  const [accent, success] = React.useMemo<[string, string]>(() => {\n    const c = colors.length ? colors : DEFAULT_COLORS;\n    return [c[0] ?? DEFAULT_COLORS[0], c[1] ?? DEFAULT_COLORS[1]];\n  }, [colors]);\n\n  const inputsRef = React.useRef<(HTMLInputElement | null)[]>([]);\n  const groupRef = React.useRef<HTMLDivElement>(null);\n  const completedRef = React.useRef(false);\n  const [focusedIndex, setFocusedIndex] = React.useState<number | null>(null);\n  const [pulseKey, setPulseKey] = React.useState(0);\n\n  const isControlled = value !== undefined;\n  const [internal, setInternal] = React.useState<string[]>(() =>\n    Array.from({ length: count }, (_, i) => defaultValue[i] ?? \"\")\n  );\n\n  // Derive the fixed-length cell array from whichever source of truth is active.\n  const chars = React.useMemo<string[]>(() => {\n    const source = isControlled ? (value ?? \"\") : internal.join(\"\");\n    return Array.from({ length: count }, (_, i) => source[i] ?? \"\");\n  }, [isControlled, value, internal, count]);\n\n  const complete = chars.every((c) => c !== \"\");\n\n  const commit = React.useCallback(\n    (next: string[]) => {\n      if (!isControlled) setInternal(next);\n      onChange?.(next.join(\"\"));\n    },\n    [isControlled, onChange]\n  );\n\n  // Fire onComplete once per completion; re-arm when the code is edited back down.\n  React.useEffect(() => {\n    if (complete && !completedRef.current) {\n      completedRef.current = true;\n      if (animate) setPulseKey((k) => k + 1);\n      onComplete?.(chars.join(\"\"));\n    } else if (!complete && completedRef.current) {\n      completedRef.current = false;\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [complete]);\n\n  const focusCell = React.useCallback(\n    (index: number) => {\n      const clamped = Math.max(0, Math.min(count - 1, index));\n      const el = inputsRef.current[clamped];\n      if (el) {\n        el.focus();\n        el.select();\n      }\n    },\n    [count]\n  );\n\n  const setCharAt = React.useCallback(\n    (index: number, char: string) => {\n      const next = [...chars];\n      next[index] = char;\n      commit(next);\n    },\n    [chars, commit]\n  );\n\n  const distribute = React.useCallback(\n    (start: number, incoming: string) => {\n      if (!incoming) return;\n      const next = [...chars];\n      let idx = start;\n      for (const ch of incoming) {\n        if (idx >= count) break;\n        next[idx] = ch;\n        idx += 1;\n      }\n      commit(next);\n      focusCell(Math.min(idx, count - 1));\n    },\n    [chars, commit, count, focusCell]\n  );\n\n  const handleChange = (index: number, event: React.ChangeEvent<HTMLInputElement>) => {\n    let incoming = sanitize(event.target.value, mode);\n    if (!incoming) {\n      setCharAt(index, \"\");\n      return;\n    }\n    // Typing into an already-filled cell without the text selected (e.g. after\n    // a click repositioned the caret) yields old+new — keep only the new char\n    // instead of spilling the old one into the next cell.\n    const existing = chars[index];\n    if (existing && incoming.length === 2) {\n      incoming = incoming[0] === existing ? incoming.slice(1) : incoming.slice(0, 1);\n    }\n    distribute(index, incoming);\n  };\n\n  const handleKeyDown = (index: number, event: React.KeyboardEvent<HTMLInputElement>) => {\n    switch (event.key) {\n      case \"Backspace\":\n        event.preventDefault();\n        if (chars[index]) {\n          setCharAt(index, \"\");\n        } else if (index > 0) {\n          setCharAt(index - 1, \"\");\n          focusCell(index - 1);\n        }\n        break;\n      case \"Delete\":\n        event.preventDefault();\n        setCharAt(index, \"\");\n        break;\n      case \"ArrowLeft\":\n        event.preventDefault();\n        focusCell(index - 1);\n        break;\n      case \"ArrowRight\":\n        event.preventDefault();\n        focusCell(index + 1);\n        break;\n      case \"Home\":\n        event.preventDefault();\n        focusCell(0);\n        break;\n      case \"End\":\n        event.preventDefault();\n        focusCell(count - 1);\n        break;\n      default:\n        break;\n    }\n  };\n\n  const handlePaste = (index: number, event: React.ClipboardEvent<HTMLInputElement>) => {\n    event.preventDefault();\n    const text = sanitize(event.clipboardData.getData(\"text\"), mode);\n    distribute(index, text);\n  };\n\n  const handleBlur = (event: React.FocusEvent<HTMLInputElement>) => {\n    if (!groupRef.current?.contains(event.relatedTarget as Node | null)) {\n      setFocusedIndex(null);\n    }\n  };\n\n  const joined = chars.join(\"\");\n  // Spring time-scale ∝ 1/sqrt(stiffness): scale stiffness by spd² and damping\n  // by spd so `speed` speeds the pop up/down while keeping the same overshoot.\n  const popTransition = {\n    type: \"spring\" as const,\n    stiffness: 560 * spd * spd,\n    damping: 22 * spd,\n    mass: 0.6,\n  };\n\n  return (\n    <div\n      ref={groupRef}\n      role=\"group\"\n      aria-label={ariaLabel}\n      data-crucible=\"passcode\"\n      className={cn(\n        \"relative inline-flex items-center\",\n        sz.gap,\n        disabled && \"opacity-50\",\n        className\n      )}\n    >\n      {chars.map((char, i) => {\n        const isFocused = focusedIndex === i;\n        const showCaret = isFocused && !char && !disabled;\n        const cellColor = complete ? success : isFocused ? accent : undefined;\n\n        return (\n          <div\n            key={i}\n            className={cn(\"relative flex items-center justify-center\", sz.cell)}\n          >\n            {/* Cell frame — neutral at rest, accent on focus, success on complete. */}\n            <div\n              aria-hidden\n              className={cn(\n                \"pointer-events-none absolute inset-0 border bg-neutral-900/40 transition-colors\",\n                sz.radius,\n                char ? \"border-neutral-600\" : \"border-neutral-800\",\n                complete && \"border-transparent\"\n              )}\n              style={{\n                borderColor: cellColor,\n                boxShadow: isFocused && !complete ? `0 0 0 3px ${accent}33` : undefined,\n                transitionDuration: reducedMotion ? \"0ms\" : \"180ms\",\n              }}\n            />\n\n            {/* Success flash — a one-shot glow when the last cell fills. */}\n            {animate && complete && (\n              <motion.div\n                key={pulseKey}\n                aria-hidden\n                className={cn(\"pointer-events-none absolute inset-0\", sz.radius)}\n                initial={{ opacity: 0.55, boxShadow: `0 0 0 4px ${success}66` }}\n                animate={{ opacity: 0, boxShadow: `0 0 0 10px ${success}00` }}\n                transition={{ duration: 0.5 / spd, ease: \"easeOut\" }}\n              />\n            )}\n\n            {/* Visible glyph / caret. The real input above is transparent, so this\n                layer owns every pixel the user sees. Decorative → aria-hidden. */}\n            <div\n              aria-hidden\n              className=\"pointer-events-none relative z-10 flex h-full w-full items-center justify-center\"\n            >\n              <AnimatePresence mode=\"popLayout\" initial={false}>\n                {char ? (\n                  <motion.span\n                    key={`char-${char}`}\n                    className={cn(\"font-medium tabular-nums\", sz.glyph)}\n                    style={{ color: complete ? success : \"#f5f5f5\" }}\n                    initial={animate ? { scale: 0.55, opacity: 0, y: 3 } : false}\n                    animate={{ scale: 1, opacity: 1, y: 0 }}\n                    exit={animate ? { scale: 0.55, opacity: 0 } : { opacity: 0 }}\n                    transition={animate ? popTransition : { duration: 0 }}\n                  >\n                    {masked ? (\n                      <span className={cn(\"inline-block rounded-full bg-current align-middle\", sz.dot)} />\n                    ) : (\n                      char\n                    )}\n                  </motion.span>\n                ) : showCaret ? (\n                  <motion.span\n                    key=\"caret\"\n                    className={cn(\"block w-[2px] rounded-full\", sz.caret)}\n                    style={{ backgroundColor: accent }}\n                    initial={{ opacity: 1 }}\n                    animate={animate ? { opacity: [1, 1, 0, 0] } : { opacity: 1 }}\n                    transition={\n                      animate\n                        ? { duration: 1.06 / spd, repeat: Infinity, ease: \"linear\" }\n                        : { duration: 0 }\n                    }\n                  />\n                ) : null}\n              </AnimatePresence>\n            </div>\n\n            {/* The real, accessible input — transparent text + caret so the layer\n                above renders instead. Holds exactly one character of state. */}\n            <input\n              ref={(el) => {\n                inputsRef.current[i] = el;\n              }}\n              type=\"text\"\n              inputMode={mode === \"numeric\" ? \"numeric\" : \"text\"}\n              pattern={mode === \"numeric\" ? \"[0-9]*\" : undefined}\n              autoComplete={i === 0 ? \"one-time-code\" : \"off\"}\n              // eslint-disable-next-line jsx-a11y/no-autofocus\n              autoFocus={autoFocus && i === 0}\n              disabled={disabled}\n              value={char}\n              aria-label={`${mode === \"numeric\" ? \"Digit\" : \"Character\"} ${i + 1} of ${count}`}\n              onChange={(e) => handleChange(i, e)}\n              onKeyDown={(e) => handleKeyDown(i, e)}\n              onPaste={(e) => handlePaste(i, e)}\n              onFocus={(e) => {\n                setFocusedIndex(i);\n                e.target.select();\n              }}\n              onBlur={handleBlur}\n              className={cn(\n                \"absolute inset-0 z-20 h-full w-full bg-transparent text-center\",\n                sz.radius,\n                \"text-transparent caret-transparent outline-none\",\n                \"selection:bg-transparent selection:text-transparent\",\n                \"disabled:cursor-not-allowed\"\n              )}\n            />\n          </div>\n        );\n      })}\n\n      {name ? <input type=\"hidden\" name={name} value={joined} /> : null}\n    </div>\n  );\n}\n"
    }
  ]
}