{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "otp-input",
  "title": "OTP Input",
  "description": "A one-time passcode field split into separate digit boxes — the \"enter the 6-digit code we sent you\" screen. Reach for it on any challenge step where a short numeric code is typed or pasted: two-factor and multi-factor sign-in (2FA/MFA), an authenticator app's TOTP code, an SMS or phone verification code, an email confirmation code, a magic-link fallback code, account recovery, a step-up check before a payment or a destructive settings change, device or TV pairing, and PIN entry. Common asks it answers: \"otp input\", \"one time password input\", \"verification code input\", \"6 digit code input\", \"enter code boxes\", \"2fa code field\", \"sms code input\", \"confirmation code input\", \"pin input react\", \"segmented code input\", \"react-otp-input alternative\", \"input-otp alternative\", \"otp input without dependencies\", \"shadcn otp input\". Official shadcn/ui covers the same screen with input-otp, and the difference is the dependency: that one is a wrapper around the third-party input-otp package (plus lucide-react for its separator), so installing it adds a runtime dependency and hands the caret and paste behaviour to a library. This is the same boxed UI written out in a single file with no dependencies at all — reach for it when the project is keeping its dependency list short, when a package has to be vendored or audited before it can be added, or when you want the behaviour in code you can read and change rather than configure. Digits only, by design: the value is stripped to digits and truncated to `length` (default 6) on every path in, so a controlled parent, a paste and a keystroke cannot disagree about what is in the field. Pasting a full code into any box distributes it across the rest and lands the caret on the last filled one, typing auto-advances, Backspace clears and steps back, and the arrow keys, Home and End move between boxes. The first box carries autocomplete=\"one-time-code\", so iOS and Android offer the code from the incoming SMS, and every box is inputMode=\"numeric\" with pattern=\"[0-9]*\" for a numeric keypad on mobile. Every box is labelled \"Digit N of 6\" and the group carries a name of its own, so a screen reader user is told where they are instead of hearing six unlabelled text fields. Works controlled (`value` + `onChange`) or uncontrolled (`defaultValue`), fires `onComplete` once when the last box fills — on the fill only, not on every later edit — and forwards a ref to the first box so a page can focus it on mount or from a shortcut. Passing `name` mirrors the joined value into a hidden input, so it posts with a plain HTML form, a Next.js server action, or React Hook Form without a controller. Themed with shadcn tokens, so it follows dark mode.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/ui/otp-input.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\ninterface OtpInputProps {\n  /** Number of single-character slots (default 6). */\n  length?: number\n  /** Controlled value; non-digits are ignored and it is truncated to `length`. */\n  value?: string\n  /** Initial value when uncontrolled. */\n  defaultValue?: string\n  /** Fires with the full joined string on every change. */\n  onChange?: (value: string) => void\n  /** Fires once when the last empty slot is filled (e.g. auto-submit the code). */\n  onComplete?: (value: string) => void\n  /** Disable every slot. */\n  disabled?: boolean\n  /** Focus the first slot on mount. */\n  autoFocus?: boolean\n  /** Accessible label for the slot group (default \"Verification code\"). */\n  \"aria-label\"?: string\n  /** When set, a hidden input mirrors the value so it submits with a native form. */\n  name?: string\n  className?: string\n}\n\n// Keep only digits, cap to `length`, and pad to a fixed-length array so every\n// render maps one slot to one input.\nfunction toSlots(value: string | undefined, length: number) {\n  const digits = (value ?? \"\").replace(/\\D/g, \"\").slice(0, length).split(\"\")\n  return Array.from({ length }, (_, i) => digits[i] ?? \"\")\n}\n\nexport const OtpInput = React.forwardRef<HTMLInputElement, OtpInputProps>(\n  function OtpInput(\n    {\n      length = 6,\n      value,\n      defaultValue,\n      onChange,\n      onComplete,\n      disabled,\n      autoFocus,\n      name,\n      className,\n      \"aria-label\": ariaLabel = \"Verification code\",\n    },\n    forwardedRef\n  ) {\n    const isControlled = value !== undefined\n    const inputsRef = React.useRef<Array<HTMLInputElement | null>>([])\n    const [slots, setSlots] = React.useState(() =>\n      toSlots(isControlled ? value : defaultValue, length)\n    )\n\n    // Forward the first slot so callers can focus the field from a shortcut.\n    React.useImperativeHandle(\n      forwardedRef,\n      () => inputsRef.current[0] as HTMLInputElement\n    )\n\n    // Re-seed from the prop when controlled, but only when the prop says something the slots do\n    // not already say. The comparison has to happen on the joined form, because that is the only\n    // thing the parent was ever told: a code with a hole in it — \"\" \"2\" \"3\" — joins to \"23\", and\n    // re-seeding from \"23\" would pack those digits back to the left. Skipping the echo of our own\n    // emit is what lets a slot stay empty while later ones are filled.\n    React.useEffect(() => {\n      if (!isControlled) return\n      const next = toSlots(value, length)\n      if (next.join(\"\") === slots.join(\"\")) return\n      setSlots(next)\n    }, [isControlled, value, length, slots])\n\n    React.useEffect(() => {\n      if (autoFocus) inputsRef.current[0]?.focus()\n    }, [autoFocus])\n\n    function focusSlot(index: number) {\n      const el = inputsRef.current[Math.max(0, Math.min(index, length - 1))]\n      el?.focus()\n      el?.select()\n    }\n\n    // Single commit path: store the slots, fire onChange, and fire onComplete only on the\n    // transition into a fully filled code. The slots are always local state, controlled or not —\n    // which slot a digit sits in is not recoverable from the string the parent holds, so a\n    // controlled field that waited for the prop to come back would lose every gap.\n    function commit(next: string[]) {\n      const wasFull = slots.every((s) => s !== \"\")\n      setSlots(next)\n      const joined = next.join(\"\")\n      onChange?.(joined)\n      if (!wasFull && next.every((s) => s !== \"\")) onComplete?.(joined)\n    }\n\n    // Spread a multi-digit string across slots starting at `index` (paste and\n    // OS one-time-code autofill).\n    function fillFrom(index: number, digits: string) {\n      const chars = digits.replace(/\\D/g, \"\").split(\"\")\n      if (chars.length === 0) return\n      const next = [...slots]\n      let i = index\n      for (const c of chars) {\n        if (i >= length) break\n        next[i] = c\n        i++\n      }\n      commit(next)\n      focusSlot(Math.min(i, length - 1))\n    }\n\n    function handleChange(\n      index: number,\n      e: React.ChangeEvent<HTMLInputElement>\n    ) {\n      const raw = e.target.value.replace(/\\D/g, \"\")\n      if (raw === \"\") {\n        const next = [...slots]\n        next[index] = \"\"\n        commit(next)\n        return\n      }\n      // When a slot already holds a digit, the change value is \"old+new\"; drop\n      // the kept prefix so we read just the freshly typed character.\n      const prev = slots[index]\n      let incoming = raw\n      if (prev && incoming.startsWith(prev)) incoming = incoming.slice(prev.length)\n      if (incoming === \"\") return\n      if (incoming.length > 1) {\n        fillFrom(index, incoming)\n        return\n      }\n      const next = [...slots]\n      next[index] = incoming\n      commit(next)\n      if (index < length - 1) focusSlot(index + 1)\n    }\n\n    function handleKeyDown(\n      index: number,\n      e: React.KeyboardEvent<HTMLInputElement>\n    ) {\n      switch (e.key) {\n        case \"Backspace\": {\n          e.preventDefault()\n          const next = [...slots]\n          if (next[index] !== \"\") {\n            next[index] = \"\"\n            commit(next)\n          } else if (index > 0) {\n            next[index - 1] = \"\"\n            commit(next)\n            focusSlot(index - 1)\n          }\n          break\n        }\n        case \"Delete\": {\n          e.preventDefault()\n          const next = [...slots]\n          next[index] = \"\"\n          commit(next)\n          break\n        }\n        case \"ArrowLeft\":\n          e.preventDefault()\n          focusSlot(index - 1)\n          break\n        case \"ArrowRight\":\n          e.preventDefault()\n          focusSlot(index + 1)\n          break\n        case \"Home\":\n          e.preventDefault()\n          focusSlot(0)\n          break\n        case \"End\":\n          e.preventDefault()\n          focusSlot(length - 1)\n          break\n      }\n    }\n\n    function handlePaste(\n      index: number,\n      e: React.ClipboardEvent<HTMLInputElement>\n    ) {\n      e.preventDefault()\n      fillFrom(index, e.clipboardData.getData(\"text\"))\n    }\n\n    const joined = slots.join(\"\")\n\n    return (\n      <div\n        role=\"group\"\n        aria-label={ariaLabel}\n        className={cn(\"inline-flex items-center gap-2\", className)}\n      >\n        {slots.map((digit, i) => (\n          <input\n            key={i}\n            ref={(el) => {\n              inputsRef.current[i] = el\n            }}\n            type=\"text\"\n            inputMode=\"numeric\"\n            pattern=\"[0-9]*\"\n            autoComplete={i === 0 ? \"one-time-code\" : \"off\"}\n            disabled={disabled}\n            value={digit}\n            aria-label={`Digit ${i + 1} of ${length}`}\n            onChange={(e) => handleChange(i, e)}\n            onKeyDown={(e) => handleKeyDown(i, e)}\n            onPaste={(e) => handlePaste(i, e)}\n            onFocus={(e) => e.target.select()}\n            className={cn(\n              \"h-10 w-10 rounded-md border border-input bg-transparent text-center text-sm font-medium tabular-nums shadow-sm transition-colors\",\n              \"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring\",\n              \"disabled:cursor-not-allowed disabled:opacity-50\"\n            )}\n          />\n        ))}\n        {name ? <input type=\"hidden\" name={name} value={joined} /> : null}\n      </div>\n    )\n  }\n)\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}