{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"otp-code-input","type":"registry:component","title":"OTP Code Input","description":"A one-time-code field whose cells survive SMS autofill, paste and Android backspace.","dependencies":[],"registryDependencies":[],"files":[{"path":"components/viberdy/otp-code-input.tsx","content":"\"use client\";\n\nimport { Fragment, useCallback, useEffect, useRef, useState } from \"react\";\n\nexport function OtpCodeInput({\n  length = 6,\n  label = \"Verification code\",\n  grouped = true,\n  onComplete,\n}: {\n  length?: number;\n  label?: string;\n  grouped?: boolean;\n  onComplete?: (code: string) => void;\n}) {\n  const [stored, setStored] = useState<string[]>([]);\n  const cells = useRef<Array<HTMLInputElement | null>>([]);\n  const groupRef = useRef<HTMLDivElement>(null);\n  // The CODE that was last reported, not a boolean. A boolean guard looks\n  // equivalent and is not — see the note in commit().\n  const firedFor = useRef<string | null>(null);\n\n  // DERIVED, not held at a fixed size. Storing a fixed-length array means a\n  // change to the length prop leaves the array and the rendered cells\n  // disagreeing, which shows up later as an off-by-one in the submitted code.\n  const digits = Array.from({ length }, (_, i) => stored[i] ?? \"\");\n\n  const focusCell = useCallback(\n    (i: number) => {\n      const el = cells.current[Math.max(0, Math.min(length - 1, i))];\n      el?.focus();\n      // Select, do not merely focus. A cell with maxLength=1 that already\n      // holds a digit REJECTS further input, so without this the user cannot\n      // overtype a digit they got wrong — they can only delete it first.\n      el?.select();\n    },\n    [length],\n  );\n\n  const commit = useCallback(\n    (next: string[]) => {\n      const trimmed = next.slice(0, length);\n      setStored(trimmed);\n      const value = trimmed.join(\"\");\n      if (value.length === length) {\n        // Fire once per DISTINCT completed code.\n        //\n        // A boolean \"have I fired yet\" guard is the obvious version and it is\n        // wrong. Select-on-focus exists so a wrong digit can be overtyped\n        // without deleting it first — and an overtype never takes the row\n        // below full, so a boolean guard is never reset and the CORRECTED\n        // code is silently never reported. The user sees a filled, plausible\n        // field and the parent never hears about it. Comparing against the\n        // last reported value fires on the correction and still cannot\n        // double-fire on a keystroke that changes nothing.\n        if (firedFor.current !== value) {\n          firedFor.current = value;\n          onComplete?.(value);\n        }\n      } else {\n        firedFor.current = null;\n      }\n    },\n    [length, onComplete],\n  );\n\n  // ONE path for every source of characters: typing, pasting, and the OS\n  // filling a code from SMS. Autofill IGNORES maxLength, so a six-digit code\n  // genuinely arrives as a single six-character change event on one cell.\n  const write = useCallback(\n    (at: number, raw: string) => {\n      const chars = raw.replace(/[^0-9]/g, \"\").split(\"\");\n      if (chars.length === 0) return;\n      const next = digits.slice();\n      let i = at;\n      for (const ch of chars) {\n        if (i >= length) break;\n        next[i] = ch;\n        i += 1;\n      }\n      commit(next);\n      focusCell(i);\n    },\n    [digits, length, commit, focusCell],\n  );\n\n  const clearAt = useCallback(\n    (i: number) => {\n      const next = digits.slice();\n      if (next[i]) {\n        next[i] = \"\";\n        commit(next);\n      } else if (i > 0) {\n        // Backspace in an empty cell deletes the digit BEFORE it and moves\n        // back, which is what every native field does.\n        next[i - 1] = \"\";\n        commit(next);\n        focusCell(i - 1);\n      }\n    },\n    [digits, commit, focusCell],\n  );\n\n  // Held in a ref so the native listener below is registered ONCE rather than\n  // being torn down and rebuilt on every keystroke.\n  const clearRef = useRef(clearAt);\n  useEffect(() => {\n    clearRef.current = clearAt;\n  }, [clearAt]);\n\n  useEffect(() => {\n    const host = groupRef.current;\n    if (!host) return;\n    // A NATIVE listener, delegated on the group.\n    //\n    // React's onBeforeInput PROP IS NOT THE NATIVE beforeinput EVENT. It is a\n    // legacy synthetic event assembled from keypress, textInput and\n    // composition events, and it never fires for a deletion — a component that\n    // relies on it has no Android backspace handling at all, silently.\n    const onBeforeInput = (e: Event) => {\n      if ((e as InputEvent).inputType !== \"deleteContentBackward\") return;\n      const i = cells.current.indexOf(e.target as HTMLInputElement);\n      if (i < 0) return;\n      e.preventDefault();\n      clearRef.current(i);\n    };\n    host.addEventListener(\"beforeinput\", onBeforeInput);\n    return () => host.removeEventListener(\"beforeinput\", onBeforeInput);\n  }, []);\n\n  return (\n    <div className=\"w-[300px]\">\n      <p className=\"mb-3 text-[12px] font-medium text-white/80\">{label}</p>\n\n      <div\n        ref={groupRef}\n        role=\"group\"\n        aria-label={label}\n        className=\"flex items-center gap-1.5\"\n      >\n        {digits.map((d, i) => (\n          <Fragment key={i}>\n            {grouped && i === Math.floor(length / 2) && (\n              <span aria-hidden className=\"mx-1 h-px w-3 shrink-0 bg-white/20\" />\n            )}\n            <input\n              ref={(el) => {\n                cells.current[i] = el;\n              }}\n              // NOT type=\"number\": it draws spinners, ignores maxLength, and\n              // accepts \"e\", \"+\" and \"-\". text + inputMode is the right pair.\n              type=\"text\"\n              inputMode=\"numeric\"\n              autoComplete=\"one-time-code\"\n              maxLength={1}\n              value={d}\n              aria-label={\"Digit \" + (i + 1) + \" of \" + length}\n              onChange={(e) => write(i, e.target.value)}\n              onFocus={(e) => e.currentTarget.select()}\n              onPaste={(e) => {\n                e.preventDefault();\n                write(i, e.clipboardData.getData(\"text\"));\n              }}\n              onKeyDown={(e) => {\n                if (e.key === \"Backspace\") {\n                  // Desktop path. preventDefault here also suppresses the\n                  // beforeinput that would otherwise follow, so the native\n                  // listener above cannot double-fire on the same press.\n                  e.preventDefault();\n                  clearAt(i);\n                } else if (e.key === \"ArrowLeft\") {\n                  e.preventDefault();\n                  focusCell(i - 1);\n                } else if (e.key === \"ArrowRight\") {\n                  e.preventDefault();\n                  focusCell(i + 1);\n                }\n              }}\n              className=\"h-12 min-w-0 flex-1 rounded-md border bg-transparent text-center font-mono text-[17px] tabular-nums text-white outline-none transition-colors focus:border-white/45\"\n              style={{\n                borderColor: d ? \"rgba(255,255,255,0.28)\" : \"rgba(255,255,255,0.10)\",\n                background: d ? \"rgba(255,255,255,0.04)\" : \"transparent\",\n              }}\n            />\n          </Fragment>\n        ))}\n      </div>\n    </div>\n  );\n}\n","type":"registry:component","target":"components/viberdy/otp-code-input.tsx"}],"meta":{"tier":"free","tags":["otp","input","form","auth","2fa"],"href":"https://viberdy.dev/library/otp-code-input","aiPrompt":"Build a one-time-code (OTP) input in React + Tailwind named OtpCodeInput — N single-character cells that behave like one field.\n\nMost OTP implementations are broken in the same four ways. Handle all of them:\n\n1. AUTOFILL IGNORES maxLength. When iOS or Android offers a code from SMS it sets ONE input's value to the whole code, even though every cell is `maxLength={1}`. So the `onChange` handler and the `onPaste` handler must be the SAME function: strip non-digits from the incoming string and distribute the characters across the cells from the current index forward. Put `autoComplete=\"one-time-code\"` on every cell — it is safe precisely because the handler distributes.\n\n2. A FULL CELL REJECTS INPUT. With `maxLength={1}` and a digit already present, typing does nothing — the user cannot correct a wrong digit, only delete it first. On every focus call `e.currentTarget.select()` so the next keystroke replaces the contents.\n\n3. ANDROID DOES NOT REPORT Backspace THROUGH keydown. Soft keyboards send keyCode 229 / `key === \"Unidentified\"`, so a keydown-only delete handler is dead on most phones. The signal that does arrive is the native `beforeinput` event with `inputType === \"deleteContentBackward\"`.\n\n   REACT'S `onBeforeInput` PROP IS NOT THAT EVENT. It is a legacy synthetic event assembled from keypress, textInput and composition events, and it NEVER fires for a deletion — wire it up and the Android path is silently dead. Attach a real listener instead: `host.addEventListener(\"beforeinput\", handler)` on the group, delegated, and find the cell with `cells.indexOf(e.target)`. Hold the handler in a ref synced from an effect so the listener registers once rather than being rebuilt on every keystroke.\n\n   Keep the keydown handler for desktop — because it calls `preventDefault()`, no `beforeinput` follows there, so the two cannot double-fire.\n\n4. `type=\"number\"` LOOKS RIGHT AND IS WRONG — spinners, ignored maxLength, and it accepts \"e\", \"+\" and \"-\". Use `type=\"text\"` with `inputMode=\"numeric\"`.\n\nAlso:\n- DERIVE the digit array from stored state at the current length (`Array.from({ length }, (_, i) => stored[i] ?? \"\")`) rather than holding a fixed-size array. A length change otherwise leaves the array and the cells disagreeing.\n- Backspace in an EMPTY cell clears the previous cell and moves back, matching native behaviour.\n- ArrowLeft / ArrowRight move between cells.\n- Fire `onComplete` ONCE per completed code, guarded by a ref, and reset the guard when the code becomes incomplete again — otherwise correcting a digit submits twice.\n- Give the wrapper `role=\"group\"` with the field label, and every cell an aria-label like \"Digit 3 of 6\", so the field is navigable and announced without reference to the visual grouping.\n\nProps: length, label, grouped (a hairline separator at the midpoint), onComplete.\n\nOutput a single self-contained \"use client\" component.","license":"https://viberdy.dev/license"}}