{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"otp-input","type":"registry:ui","title":"OTP Input","description":"A one-time-code field where a focus ring glides between slots and digits roll in; wrong codes shake, right ones draw a check.","author":"GetUrUI <hello@geturui.dev>","dependencies":["motion"],"registryDependencies":[],"files":[{"path":"registry/geturui/ui/otp-input.tsx","type":"registry:ui","target":"components/ui/otp-input.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useAnimate, useReducedMotion } from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type OtpStatus = \"idle\" | \"error\" | \"success\";\n\nexport interface OtpInputProps {\n  /** Number of slots. */\n  length?: number;\n  value?: string;\n  defaultValue?: string;\n  onChange?: (value: string) => void;\n  /**\n   * Called once every slot is filled. Return (or resolve) `true` to show success,\n   * `false` to shake and clear, or nothing to leave the status to the `status` prop.\n   */\n  onComplete?: (value: string) => boolean | void | Promise<boolean | void>;\n  /** Controlled status; overrides the result of onComplete. */\n  status?: OtpStatus;\n  /** Accept digits only, or letters and digits. */\n  mode?: \"numeric\" | \"alphanumeric\";\n  /** Insert a dash after every N slots, e.g. 3 → 123-456. */\n  groupSize?: number;\n  disabled?: boolean;\n  autoFocus?: boolean;\n  className?: string;\n  \"aria-label\"?: string;\n}\n\n/** One-time-code field: a focus ring that glides between slots, digits that roll in, paste support, an error shake and a check that draws itself. */\nexport function OtpInput({\n  length = 6,\n  value,\n  defaultValue = \"\",\n  onChange,\n  onComplete,\n  status: statusProp,\n  mode = \"numeric\",\n  groupSize,\n  disabled,\n  autoFocus,\n  className,\n  ...rest\n}: OtpInputProps) {\n  const uid = React.useId();\n  const reduce = useReducedMotion();\n  const [inner, setInner] = React.useState(defaultValue.slice(0, length));\n  const code = (value ?? inner).slice(0, length);\n  const [focused, setFocused] = React.useState<number | null>(null);\n  const [innerStatus, setInnerStatus] = React.useState<OtpStatus>(\"idle\");\n  const [checking, setChecking] = React.useState(false);\n  const status = statusProp ?? innerStatus;\n  const refs = React.useRef<(HTMLInputElement | null)[]>([]);\n  const [scope, animate] = useAnimate<HTMLDivElement>();\n  const prevStatus = React.useRef<OtpStatus>(status);\n\n  const allowed = mode === \"numeric\" ? /[0-9]/ : /[a-zA-Z0-9]/;\n  const clean = (s: string) =>\n    s\n      .split(\"\")\n      .filter((c) => allowed.test(c))\n      .join(\"\")\n      .toUpperCase();\n\n  // Shake on every transition into the error state.\n  React.useEffect(() => {\n    if (status === \"error\" && prevStatus.current !== \"error\" && scope.current && !reduce) {\n      animate(scope.current, { x: [0, -10, 9, -6, 5, -2, 0] }, { duration: 0.42, ease: \"easeOut\" });\n    }\n    prevStatus.current = status;\n  }, [status, animate, scope, reduce]);\n\n  const focusSlot = (i: number) => {\n    const el = refs.current[Math.max(0, Math.min(length - 1, i))];\n    el?.focus();\n    el?.select();\n  };\n\n  async function commit(next: string) {\n    if (value === undefined) setInner(next);\n    onChange?.(next);\n    if (innerStatus !== \"idle\") setInnerStatus(\"idle\");\n    if (next.length === length && !next.includes(\" \") && onComplete) {\n      setChecking(true);\n      try {\n        const ok = await onComplete(next);\n        if (ok === true) setInnerStatus(\"success\");\n        else if (ok === false) {\n          setInnerStatus(\"error\");\n          setTimeout(() => {\n            if (value === undefined) setInner(\"\");\n            onChange?.(\"\");\n            focusSlot(0);\n          }, 520);\n        }\n      } finally {\n        setChecking(false);\n      }\n    }\n  }\n\n  /** Write `chars` starting at slot `start`, returns the next slot to focus. */\n  function writeAt(start: number, chars: string) {\n    const arr = code.padEnd(length, \" \").split(\"\");\n    let i = start;\n    for (const c of chars) {\n      if (i >= length) break;\n      arr[i] = c;\n      i++;\n    }\n    void commit(arr.join(\"\").replace(/\\s+$/, \"\"));\n    return i;\n  }\n\n  function clearAt(i: number) {\n    const arr = code.padEnd(length, \" \").split(\"\");\n    arr[i] = \" \";\n    void commit(arr.join(\"\").replace(/\\s+$/, \"\"));\n  }\n\n  function onInput(i: number, e: React.ChangeEvent<HTMLInputElement>) {\n    const raw = clean(e.target.value);\n    if (!raw) return;\n    // Typing into a filled slot: keep the newest character.\n    const current = code[i] && code[i] !== \" \" ? code[i] : \"\";\n    const chars = raw.length > 1 && current && raw.startsWith(current) ? raw.slice(current.length) : raw.length > 1 && current && raw.endsWith(current) ? raw.slice(0, -current.length) : raw;\n    const next = writeAt(i, chars);\n    focusSlot(next);\n  }\n\n  function onKeyDown(i: number, e: React.KeyboardEvent<HTMLInputElement>) {\n    const filled = code[i] && code[i] !== \" \";\n    if (e.key === \"Backspace\") {\n      e.preventDefault();\n      if (filled) clearAt(i);\n      else if (i > 0) {\n        clearAt(i - 1);\n        focusSlot(i - 1);\n      }\n    } else if (e.key === \"Delete\") {\n      e.preventDefault();\n      if (filled) clearAt(i);\n    } else if (e.key === \"ArrowLeft\") {\n      e.preventDefault();\n      focusSlot(i - 1);\n    } else if (e.key === \"ArrowRight\") {\n      e.preventDefault();\n      focusSlot(i + 1);\n    } else if (e.key === \"Home\") {\n      e.preventDefault();\n      focusSlot(0);\n    } else if (e.key === \"End\") {\n      e.preventDefault();\n      focusSlot(Math.min(code.trimEnd().length, length - 1));\n    }\n  }\n\n  function onPaste(i: number, e: React.ClipboardEvent<HTMLInputElement>) {\n    e.preventDefault();\n    const chars = clean(e.clipboardData.getData(\"text\"));\n    if (!chars) return;\n    const start = chars.length >= length ? 0 : i;\n    const next = writeAt(start, chars);\n    focusSlot(next >= length ? length - 1 : next);\n  }\n\n  const tone =\n    status === \"error\" ? \"border-danger\" : status === \"success\" ? \"border-success\" : \"border-border-strong\";\n  const label = rest[\"aria-label\"] ?? \"Verification code\";\n\n  return (\n    <div className={cn(\"inline-flex flex-col items-center gap-3\", className)}>\n      <div className=\"flex items-center gap-3\">\n        <div ref={scope} role=\"group\" aria-label={label} className=\"flex items-center gap-2\" aria-busy={checking || undefined}>\n          {Array.from({ length }, (_, i) => {\n            const ch = code[i] && code[i] !== \" \" ? code[i] : \"\";\n            const on = focused === i;\n            const dash = groupSize && i > 0 && i % groupSize === 0;\n            return (\n              <React.Fragment key={i}>\n                {dash && <span aria-hidden className=\"h-0.5 w-2.5 rounded-full bg-border-strong\" />}\n                <div\n                  className={cn(\n                    \"relative grid h-14 w-11 place-items-center rounded-[14px] border bg-surface-sunken transition-colors duration-200 sm:w-12\",\n                    tone,\n                    disabled && \"opacity-45\",\n                  )}\n                >\n                  <input\n                    ref={(el) => {\n                      refs.current[i] = el;\n                    }}\n                    id={`${uid}-${i}`}\n                    value={ch}\n                    onChange={(e) => onInput(i, e)}\n                    onKeyDown={(e) => onKeyDown(i, e)}\n                    onPaste={(e) => onPaste(i, e)}\n                    onFocus={(e) => {\n                      setFocused(i);\n                      e.currentTarget.select();\n                    }}\n                    onBlur={(e) => {\n                      const to = e.relatedTarget as HTMLElement | null;\n                      if (!to || !refs.current.includes(to as HTMLInputElement)) setFocused(null);\n                    }}\n                    autoFocus={autoFocus && i === 0}\n                    disabled={disabled}\n                    inputMode={mode === \"numeric\" ? \"numeric\" : \"text\"}\n                    autoComplete={i === 0 ? \"one-time-code\" : \"off\"}\n                    autoCapitalize=\"characters\"\n                    pattern={mode === \"numeric\" ? \"[0-9]*\" : undefined}\n                    aria-label={`${label}, character ${i + 1} of ${length}`}\n                    aria-invalid={status === \"error\" || undefined}\n                    className=\"absolute inset-0 size-full cursor-text rounded-[14px] bg-transparent text-center text-transparent caret-transparent outline-none selection:bg-transparent\"\n                  />\n                  <span aria-hidden className=\"pointer-events-none relative h-8 overflow-hidden font-mono text-[26px] font-semibold leading-8 text-ink\">\n                    <AnimatePresence mode=\"popLayout\" initial={false}>\n                      {ch ? (\n                        <motion.span\n                          key={ch + i}\n                          className=\"block\"\n                          initial={reduce ? { opacity: 0 } : { y: 22, opacity: 0, filter: \"blur(4px)\" }}\n                          animate={{ y: 0, opacity: 1, filter: \"blur(0px)\" }}\n                          exit={reduce ? { opacity: 0 } : { y: -18, opacity: 0, filter: \"blur(4px)\" }}\n                          transition={{ type: \"spring\", stiffness: 520, damping: 30 }}\n                        >\n                          {ch}\n                        </motion.span>\n                      ) : on ? (\n                        <motion.span\n                          key=\"caret\"\n                          className=\"mt-1.5 block h-5 w-[2px] rounded-full bg-volt-ink\"\n                          initial={{ opacity: 0 }}\n                          animate={{ opacity: [1, 1, 0, 0] }}\n                          exit={{ opacity: 0, transition: { duration: 0.05 } }}\n                          transition={{ duration: 1, repeat: Infinity, times: [0, 0.5, 0.5, 1] }}\n                        />\n                      ) : null}\n                    </AnimatePresence>\n                  </span>\n                  {on && status !== \"success\" && (\n                    <motion.span\n                      layoutId={`${uid}-ring`}\n                      aria-hidden\n                      transition={reduce ? { duration: 0 } : { type: \"spring\", stiffness: 520, damping: 34 }}\n                      className=\"pointer-events-none absolute -inset-px rounded-[14px] border-2 border-volt-ink shadow-[0_0_0_4px_var(--volt-soft)]\"\n                    />\n                  )}\n                </div>\n              </React.Fragment>\n            );\n          })}\n        </div>\n        <AnimatePresence>\n          {status === \"success\" && (\n            <motion.span\n              key=\"check\"\n              initial={{ scale: 0.6, opacity: 0 }}\n              animate={{ scale: 1, opacity: 1 }}\n              exit={{ scale: 0.6, opacity: 0 }}\n              transition={{ type: \"spring\", stiffness: 420, damping: 22 }}\n              className=\"grid size-9 shrink-0 place-items-center rounded-full bg-success/15 text-success\"\n            >\n              <svg viewBox=\"0 0 24 24\" className=\"size-5\" fill=\"none\" aria-hidden>\n                <motion.path\n                  d=\"M5 12.5l4.5 4.5L19 7.5\"\n                  stroke=\"currentColor\"\n                  strokeWidth={2.6}\n                  strokeLinecap=\"round\"\n                  strokeLinejoin=\"round\"\n                  initial={{ pathLength: 0 }}\n                  animate={{ pathLength: 1 }}\n                  transition={{ duration: reduce ? 0 : 0.45, delay: reduce ? 0 : 0.12, ease: [0.22, 1, 0.36, 1] }}\n                />\n              </svg>\n            </motion.span>\n          )}\n        </AnimatePresence>\n      </div>\n      <p aria-live=\"polite\" className=\"sr-only\">\n        {checking ? \"Checking code\" : status === \"success\" ? \"Code verified\" : status === \"error\" ? \"That code is incorrect\" : \"\"}\n      </p>\n    </div>\n  );\n}\n"},{"path":"LICENSE-geturui.md","type":"registry:file","target":"LICENSE-geturui.md","content":"MIT License\n\nCopyright (c) 2026 geturui.com\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"}],"categories":["components","otp","one-time code","verification","2fa","input","form","paste"],"meta":{"tier":"free","license":"MIT","docs":"https://geturui.com/docs/otp-input"}}