{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"otp-input","type":"registry:ui","title":"OTP Input","description":"Auto advance, paste, error recovery.","dependencies":["motion"],"categories":["input"],"docs":"https://www.interior.dev/docs/otp-input","files":[{"path":"registry/interior/otp-input.tsx","type":"registry:ui","target":"components/interior/otp-input.tsx","content":"\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useImperativeHandle,\n  useRef,\n  useState,\n  type ChangeEvent,\n  type ClipboardEvent,\n  type FocusEvent,\n  type KeyboardEvent,\n} from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nconst CROSSFADE = { type: \"spring\", stiffness: 260, damping: 34, mass: 0.8 } as const;\nconst EASE = [0.23, 1, 0.32, 1] as const;\n\n\nexport type OtpMode = \"numeric\" | \"alphanumeric\";\n\nconst ALLOW: Record<OtpMode, RegExp> = {\n  numeric: /^[0-9]$/,\n  alphanumeric: /^[0-9a-zA-Z]$/,\n};\n\nexport type UseOtpInputOptions = {\n  length?: number;\n  mode?: OtpMode;\n  defaultValue?: string;\n  disabled?: boolean;\n  onChange?: (value: string) => void;\n  onComplete?: (value: string) => void;\n};\n\nexport type OtpCellProps = {\n  ref: (el: HTMLInputElement | null) => void;\n  value: string;\n  disabled: boolean;\n  type: \"text\";\n  inputMode: \"numeric\" | \"text\";\n  autoComplete: string;\n  autoCorrect: \"off\";\n  autoCapitalize: \"off\";\n  spellCheck: false;\n  onChange: (e: ChangeEvent<HTMLInputElement>) => void;\n  onKeyDown: (e: KeyboardEvent<HTMLInputElement>) => void;\n  onPaste: (e: ClipboardEvent<HTMLInputElement>) => void;\n  onFocus: (e: FocusEvent<HTMLInputElement>) => void;\n  onBlur: (e: FocusEvent<HTMLInputElement>) => void;\n};\n\nexport type UseOtpInputReturn = {\n  chars: string[];\n  value: string;\n  length: number;\n  complete: boolean;\n  focusedIndex: number;\n  getCellProps: (index: number) => OtpCellProps;\n  focusAt: (index: number) => void;\n  clear: () => void;\n};\n\nexport function useOtpInput({\n  length = 6,\n  mode = \"numeric\",\n  defaultValue = \"\",\n  disabled = false,\n  onChange,\n  onComplete,\n}: UseOtpInputOptions = {}): UseOtpInputReturn {\n  const allow = ALLOW[mode];\n\n  const keep = useCallback(\n    (text: string) =>\n      text\n        .split(\"\")\n        .filter((c) => allow.test(c))\n        .join(\"\"),\n    [allow],\n  );\n\n  const [chars, setChars] = useState<string[]>(() => {\n    const seed = defaultValue\n      .split(\"\")\n      .filter((c) => ALLOW[mode].test(c))\n      .slice(0, length);\n    return Array.from({ length }, (_, i) => seed[i] ?? \"\");\n  });\n  const [focusedIndex, setFocusedIndex] = useState(-1);\n\n  const charsRef = useRef(chars);\n  charsRef.current = chars;\n\n  const refs = useRef<(HTMLInputElement | null)[]>([]);\n\n  const changed = useRef(onChange);\n  changed.current = onChange;\n  const completed = useRef(onComplete);\n  completed.current = onComplete;\n\n  useEffect(() => {\n    setChars((prev) =>\n      prev.length === length\n        ? prev\n        : Array.from({ length }, (_, i) => prev[i] ?? \"\"),\n    );\n    refs.current.length = length;\n  }, [length]);\n\n  const commit = useCallback((next: string[]) => {\n    charsRef.current = next;\n    setChars(next);\n    const value = next.join(\"\");\n    changed.current?.(value);\n    if (next.length > 0 && next.every((c) => c !== \"\")) completed.current?.(value);\n  }, []);\n\n  const focusAt = useCallback(\n    (index: number) => {\n      const el = refs.current[Math.max(0, Math.min(length - 1, index))];\n      if (!el) return;\n      el.focus();\n      el.select();\n    },\n    [length],\n  );\n\n  const fillFrom = useCallback(\n    (index: number, text: string) => {\n      const incoming = keep(text);\n      if (incoming.length === 0) return;\n      const next = [...charsRef.current];\n      let cursor = index;\n      for (const c of incoming) {\n        if (cursor >= length) break;\n        next[cursor] = c;\n        cursor += 1;\n      }\n      commit(next);\n      focusAt(cursor);\n    },\n    [commit, focusAt, keep, length],\n  );\n\n  const clear = useCallback(() => {\n    commit(Array.from({ length }, () => \"\"));\n    focusAt(0);\n  }, [commit, focusAt, length]);\n\n  const getCellProps = useCallback(\n    (index: number): OtpCellProps => ({\n      ref: (el) => {\n        refs.current[index] = el;\n      },\n      value: chars[index] ?? \"\",\n      disabled,\n      type: \"text\",\n      inputMode: mode === \"numeric\" ? \"numeric\" : \"text\",\n      autoComplete: index === 0 ? \"one-time-code\" : \"off\",\n      autoCorrect: \"off\",\n      autoCapitalize: \"off\",\n      spellCheck: false,\n      onChange: (e) => {\n        const previous = charsRef.current[index] ?? \"\";\n        const raw = e.currentTarget.value;\n        const trimmed =\n          raw.length > 1 && previous && raw.startsWith(previous)\n            ? raw.slice(previous.length)\n            : raw;\n        const incoming = keep(trimmed);\n\n        if (incoming.length === 0) {\n          if (raw.length === 0 && previous) {\n            const next = [...charsRef.current];\n            next[index] = \"\";\n            commit(next);\n          }\n          e.currentTarget.value = charsRef.current[index] ?? \"\";\n          return;\n        }\n\n        if (incoming.length === 1) {\n          const next = [...charsRef.current];\n          next[index] = incoming;\n          e.currentTarget.value = incoming;\n          commit(next);\n          if (index < length - 1) focusAt(index + 1);\n          return;\n        }\n\n        fillFrom(index, incoming);\n      },\n      onKeyDown: (e) => {\n        if (e.key === \"Backspace\") {\n          e.preventDefault();\n          const current = charsRef.current;\n          const next = [...current];\n          if (current[index]) {\n            next[index] = \"\";\n            commit(next);\n            return;\n          }\n          if (index > 0) {\n            next[index - 1] = \"\";\n            commit(next);\n            focusAt(index - 1);\n          }\n          return;\n        }\n        if (e.key === \"Delete\") {\n          e.preventDefault();\n          const next = [...charsRef.current];\n          next[index] = \"\";\n          commit(next);\n          return;\n        }\n        if (e.key === \"ArrowLeft\") {\n          e.preventDefault();\n          focusAt(index - 1);\n          return;\n        }\n        if (e.key === \"ArrowRight\") {\n          e.preventDefault();\n          focusAt(index + 1);\n          return;\n        }\n        if (e.key === \"Home\") {\n          e.preventDefault();\n          focusAt(0);\n          return;\n        }\n        if (e.key === \"End\") {\n          e.preventDefault();\n          focusAt(length - 1);\n        }\n      },\n      onPaste: (e) => {\n        e.preventDefault();\n        const text = keep(e.clipboardData.getData(\"text\"));\n        fillFrom(text.length >= length ? 0 : index, text);\n      },\n      onFocus: (e) => {\n        e.currentTarget.select();\n        const firstEmpty = charsRef.current.findIndex((c) => c === \"\");\n        if (firstEmpty !== -1 && firstEmpty < index) {\n          focusAt(firstEmpty);\n          return;\n        }\n        setFocusedIndex(index);\n      },\n      onBlur: (e) => {\n        const to = e.relatedTarget as HTMLInputElement | null;\n        if (to && refs.current.includes(to)) return;\n        setFocusedIndex(-1);\n      },\n    }),\n    [chars, commit, disabled, fillFrom, focusAt, keep, length, mode],\n  );\n\n  const value = chars.join(\"\");\n\n  return {\n    chars,\n    value,\n    length,\n    complete: chars.length > 0 && chars.every((c) => c !== \"\"),\n    focusedIndex,\n    getCellProps,\n    focusAt,\n    clear,\n  };\n}\n\nexport type OtpStatus = \"idle\" | \"error\" | \"success\";\n\nexport type OtpInputHandle = {\n  clear: () => void;\n  focus: () => void;\n};\n\nexport type OtpInputProps = {\n  length?: number;\n  mode?: OtpMode;\n  defaultValue?: string;\n  onChange?: (value: string) => void;\n  onComplete?: (value: string) => void;\n  status?: OtpStatus;\n  errorMessage?: string;\n  successMessage?: string;\n  hint?: string;\n  label?: string;\n  groupEvery?: number;\n  disabled?: boolean;\n  autoFocus?: boolean;\n  focusOnError?: boolean;\n  className?: string;\n  ref?: React.Ref<OtpInputHandle>;\n};\n\nexport function OtpInput({\n  length = 6,\n  mode = \"numeric\",\n  defaultValue = \"\",\n  onChange,\n  onComplete,\n  status = \"idle\",\n  errorMessage = \"\",\n  successMessage = \"\",\n  hint = \"\",\n  label = \"Verification code\",\n  groupEvery = 3,\n  disabled = false,\n  autoFocus = false,\n  focusOnError = true,\n  className = \"\",\n  ref,\n}: OtpInputProps) {\n  const reduced = useReducedMotion();\n  const statusId = useId();\n\n  const { chars, focusedIndex, getCellProps, focusAt, clear } = useOtpInput({\n    length,\n    mode,\n    defaultValue,\n    disabled,\n    onChange,\n    onComplete,\n  });\n\n  const wasError = useRef(false);\n  const error = status === \"error\";\n  const success = status === \"success\";\n\n  useImperativeHandle(\n    ref,\n    () => ({\n      clear: () => {\n        clear();\n        focusAt(0);\n      },\n      focus: () => focusAt(0),\n    }),\n    [clear, focusAt],\n  );\n\n  useEffect(() => {\n    if (error && !wasError.current && focusOnError && !disabled) focusAt(0);\n    wasError.current = error;\n  }, [error, focusOnError, disabled, focusAt]);\n\n  useEffect(() => {\n    if (autoFocus && !disabled) focusAt(0);\n  }, [autoFocus, disabled, focusAt]);\n\n  const enter = reduced ? { duration: 0 } : { duration: 0.22, ease: EASE };\n  const swap = reduced ? { duration: 0 } : CROSSFADE;\n  const hasStatus =\n    hint.length > 0 || errorMessage.length > 0 || successMessage.length > 0;\n\n  const message = error ? errorMessage : success ? successMessage : hint;\n  const messageTone = error\n    ? \"text-red-600 dark:text-red-400\"\n    : success\n      ? \"text-emerald-600 dark:text-emerald-400\"\n      : \"text-stone-500 dark:text-stone-400\";\n\n  return (\n    <div className={`inline-flex flex-col ${className}`}>\n      <motion.div\n        role=\"group\"\n        aria-label={label}\n        className=\"relative flex gap-2\"\n        initial={false}\n        variants={{ idle: { x: 0 }, wrong: { x: [0, -5, 4, -3, 0] } }}\n        animate={error && !reduced ? \"wrong\" : \"idle\"}\n        transition={{ duration: 0.32, ease: EASE }}\n      >\n        {Array.from({ length }, (_, i) => {\n          const char = chars[i] ?? \"\";\n          const active = focusedIndex === i;\n          const gap = groupEvery > 0 && i > 0 && i % groupEvery === 0;\n\n          return (\n            <div\n              key={i}\n              className={`relative h-12 w-10 ${gap ? \"ml-3\" : \"\"}`}\n            >\n              <input\n                {...getCellProps(i)}\n                aria-label={`${label}, character ${i + 1} of ${length}`}\n                aria-invalid={error || undefined}\n                aria-describedby={hasStatus ? statusId : undefined}\n                className={`h-12 w-10 rounded-[10px] border-2 text-center text-[15px] text-transparent caret-transparent outline-none transition-[background-color,border-color,box-shadow] duration-150 selection:bg-transparent focus-visible:outline-none disabled:opacity-50 ${\n                  error\n                    ? \"border-red-500 bg-white dark:border-red-400 dark:bg-[#252522]\"\n                    : success\n                      ? \"border-emerald-500 bg-white dark:border-emerald-400 dark:bg-[#252522]\"\n                      : active\n                        ? \"border-[#4568FF] bg-white dark:border-[#93B0FF] dark:bg-[#252522]\"\n                        : char\n                          ? \"border-stone-300 bg-white dark:border-white/20 dark:bg-[#252522]\"\n                          : \"border-stone-200 bg-stone-100/70 shadow-[inset_0_1px_2px_rgba(28,25,23,0.07)] dark:border-white/[0.08] dark:bg-[#1D1D1A] dark:shadow-[inset_0_1px_2px_rgba(0,0,0,0.45)]\"\n                }`}\n              />\n\n              <span\n                aria-hidden\n                className=\"pointer-events-none absolute inset-0 grid place-items-center\"\n              >\n                <AnimatePresence initial={false} mode=\"popLayout\">\n                  {char ? (\n                    <motion.span\n                      key={char}\n                      initial={\n                        reduced\n                          ? false\n                          : { opacity: 0, scale: 0.97, y: 10, filter: \"blur(6px)\" }\n                      }\n                      animate={{ opacity: 1, scale: 1, y: 0, filter: \"blur(0px)\" }}\n                      exit={\n                        reduced\n                          ? { opacity: 0 }\n                          : { opacity: 0, scale: 0.98, y: -6, filter: \"blur(3px)\" }\n                      }\n                      transition={enter}\n                      className=\"col-start-1 row-start-1 font-mono text-[15px] tabular-nums text-stone-700 dark:text-stone-200\"\n                    >\n                      {char}\n                    </motion.span>\n                  ) : null}\n                </AnimatePresence>\n\n                {active && !char && !disabled ? (\n                  <motion.span\n                    className=\"col-start-1 row-start-1 block h-[17px] w-[1.5px] rounded-[1px] bg-stone-700 dark:bg-stone-200\"\n                    initial={{ opacity: 1 }}\n                    animate={reduced ? { opacity: 1 } : { opacity: [1, 1, 0, 0] }}\n                    transition={\n                      reduced\n                        ? { duration: 0 }\n                        : {\n                            duration: 1.06,\n                            times: [0, 0.5, 0.5, 1],\n                            repeat: Infinity,\n                            ease: \"linear\",\n                          }\n                    }\n                  />\n                ) : null}\n              </span>\n            </div>\n          );\n        })}\n      </motion.div>\n\n      {hasStatus && (\n        <>\n          <div aria-hidden className=\"mt-2 grid h-4 text-[11.5px] leading-[16px]\">\n            <AnimatePresence initial={false} mode=\"wait\">\n              <motion.span\n                key={status}\n                initial={reduced ? { opacity: 0 } : { opacity: 0, y: 3 }}\n                animate={{ opacity: 1, y: 0 }}\n                exit={reduced ? { opacity: 0 } : { opacity: 0, y: -3 }}\n                transition={swap}\n                className={`col-start-1 row-start-1 ${messageTone}`}\n              >\n                {message}\n              </motion.span>\n            </AnimatePresence>\n          </div>\n          <span id={statusId} role=\"status\" className=\"sr-only\">\n            {message}\n          </span>\n        </>\n      )}\n    </div>\n  );\n}\n"}]}