{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "otp-input",
  "type": "registry:ui",
  "title": "Otp input",
  "description": "A React one time code input where one real input powers the slots, so paste, SMS autofill and screen readers work natively. Has groups, error shake and success.",
  "dependencies": [],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/ui/otp-input.tsx",
      "type": "registry:ui",
      "target": "components/ui/otp-input.tsx",
      "content": "\"use client\"\nimport * as React from \"react\"\nimport { cn } from \"@/lib/utils\"\n\n/*\n * GOLD STANDARD COMPONENT.\n * One real <input> sits invisibly over the slots, so paste, SMS autofill,\n * backspace, selection and screen readers all behave natively. The slots are\n * pure paint: a fake caret, a glyph that settles in, a shake on error.\n */\n\ntype Status = \"idle\" | \"invalid\" | \"success\"\n\ntype OtpInputProps = {\n  length?: number\n  value?: string\n  defaultValue?: string\n  onChange?: (value: string) => void\n  /** Fires once when every slot is filled. */\n  onComplete?: (value: string) => void\n  /** Split the slots into groups, e.g. [3, 3] renders \"123 – 456\". */\n  groups?: number[]\n  pattern?: \"numeric\" | \"alphanumeric\"\n  status?: Status\n  disabled?: boolean\n  autoFocus?: boolean\n  name?: string\n  size?: \"default\" | \"lg\"\n  className?: string\n  \"aria-label\"?: string\n}\n\nconst RX = { numeric: /[^0-9]/g, alphanumeric: /[^0-9a-zA-Z]/g }\n\nfunction OtpInput({\n  length = 6,\n  value: valueProp,\n  defaultValue = \"\",\n  onChange,\n  onComplete,\n  groups,\n  pattern = \"numeric\",\n  status = \"idle\",\n  disabled,\n  autoFocus,\n  name,\n  size = \"default\",\n  className,\n  \"aria-label\": ariaLabel = \"One-time code\",\n}: OtpInputProps) {\n  const [inner, setInner] = React.useState(defaultValue)\n  const value = (valueProp ?? inner).slice(0, length)\n  const [focused, setFocused] = React.useState(false)\n  const [sel, setSel] = React.useState<[number, number]>([value.length, value.length])\n  const ref = React.useRef<HTMLInputElement>(null)\n  const [shakeKey, setShakeKey] = React.useState(0)\n\n  React.useEffect(() => {\n    if (status === \"invalid\") setShakeKey((k) => k + 1)\n  }, [status])\n\n  const commit = (raw: string) => {\n    const clean = raw.replace(RX[pattern], \"\").slice(0, length)\n    const next = pattern === \"alphanumeric\" ? clean.toUpperCase() : clean\n    if (valueProp === undefined) setInner(next)\n    onChange?.(next)\n    if (next.length === length && value.length !== length) onComplete?.(next)\n  }\n\n  const syncSel = () => {\n    const el = ref.current\n    if (!el) return\n    const start = el.selectionStart ?? 0\n    const end = el.selectionEnd ?? start\n    setSel([start, end])\n  }\n\n  // Slot layout: [3,3] → [[0,1,2],[3,4,5]]\n  const layout = React.useMemo(() => {\n    const g = groups && groups.reduce((a, b) => a + b, 0) === length ? groups : [length]\n    let n = 0\n    return g.map((count) => Array.from({ length: count }, () => n++))\n  }, [groups, length])\n\n  const activeIndex = Math.min(sel[0], length - 1)\n  const tone =\n    status === \"invalid\" ? \"danger\" : status === \"success\" ? \"success\" : \"idle\"\n\n  return (\n    <div\n      data-slot=\"otp-input\"\n      data-status={status}\n      className={cn(\"relative inline-flex w-fit\", disabled && \"cursor-not-allowed opacity-50\", className)}\n    >\n      <input\n        ref={ref}\n        name={name}\n        value={value}\n        disabled={disabled}\n        autoFocus={autoFocus}\n        aria-label={ariaLabel}\n        aria-invalid={status === \"invalid\" || undefined}\n        autoComplete=\"one-time-code\"\n        inputMode={pattern === \"numeric\" ? \"numeric\" : \"text\"}\n        pattern={pattern === \"numeric\" ? \"[0-9]*\" : \"[0-9a-zA-Z]*\"}\n        maxLength={length}\n        spellCheck={false}\n        onChange={(e) => {\n          commit(e.target.value)\n          requestAnimationFrame(syncSel)\n        }}\n        onFocus={() => {\n          setFocused(true)\n          // Always land at the end so typing continues where the code stops.\n          requestAnimationFrame(() => {\n            const el = ref.current\n            if (el) el.setSelectionRange(el.value.length, el.value.length)\n            syncSel()\n          })\n        }}\n        onBlur={() => setFocused(false)}\n        onSelect={syncSel}\n        onKeyUp={syncSel}\n        className=\"absolute inset-0 z-10 w-full cursor-text bg-transparent text-transparent caret-transparent outline-none selection:bg-transparent disabled:cursor-not-allowed\"\n        style={{ letterSpacing: \"-0.5em\", fontSize: 1 }}\n      />\n      <div\n        key={shakeKey}\n        aria-hidden\n        className={cn(\"flex items-center gap-2\", shakeKey > 0 && \"animate-[shake-x_360ms_var(--ease-hairline)]\")}\n      >\n        {layout.map((slots, gi) => (\n          <React.Fragment key={gi}>\n            {gi > 0 ? <span className=\"h-px w-3 rounded-full bg-border-strong\" /> : null}\n            <div className=\"flex items-center gap-1.5\">\n              {slots.map((i) => {\n                const ch = value[i]\n                const isActive = focused && (sel[0] === sel[1] ? i === activeIndex && (value.length < length || i === length - 1) : i >= sel[0] && i < sel[1])\n                const showCaret = focused && sel[0] === sel[1] && i === value.length && value.length < length\n                return (\n                  <div\n                    key={i}\n                    data-active={isActive || undefined}\n                    data-filled={ch ? \"\" : undefined}\n                    className={cn(\n                      \"relative grid place-items-center rounded-lg border bg-surface font-mono font-medium text-fg tabular-nums shadow-xs\",\n                      \"transition-[border-color,box-shadow,background-color] duration-[140ms] ease-hairline\",\n                      size === \"lg\" ? \"h-14 w-12 text-2xl\" : \"h-11 w-10 text-lg\",\n                      tone === \"idle\" && (ch ? \"border-border-strong\" : \"border-border\"),\n                      tone === \"idle\" && isActive && \"border-accent shadow-[0_0_0_3px_var(--accent-soft)]\",\n                      tone === \"danger\" && \"border-danger bg-[color-mix(in_oklch,var(--danger)_5%,var(--surface))] text-danger\",\n                      tone === \"success\" && \"border-success bg-[color-mix(in_oklch,var(--success)_6%,var(--surface))]\"\n                    )}\n                  >\n                    {ch ? (\n                      <span key={ch + i} className=\"animate-[glyph-in_180ms_var(--ease-hairline)_both]\">\n                        {ch}\n                      </span>\n                    ) : showCaret ? (\n                      <span className=\"h-[45%] w-px animate-[caret-blink_1.1s_steps(1)_infinite] bg-fg\" />\n                    ) : (\n                      <span className=\"size-1 rounded-full bg-border-strong\" />\n                    )}\n                  </div>\n                )\n              })}\n            </div>\n          </React.Fragment>\n        ))}\n      </div>\n    </div>\n  )\n}\n\nexport { OtpInput }\nexport type { OtpInputProps }\n"
    }
  ]
}
