{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "otp-input",
  "title": "OTP Input",
  "description": "A fixed-slot one-time passcode input with paste, autofill, error shake, and animated success feedback.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://ui.ericts.com/r/check-animation.json"
  ],
  "files": [
    {
      "path": "registry/base/ui/otp-input.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useReducedMotion,\n} from \"motion/react\";\n\nimport { CheckAnimation } from \"@/components/ui/check-animation\";\nimport { cn } from \"@/lib/utils\";\n\nexport type OTPStatus = \"idle\" | \"error\" | \"success\";\n\nexport interface OTPInputProps\n  extends Omit<\n    React.ComponentPropsWithoutRef<\"div\">,\n    \"defaultValue\" | \"onChange\"\n  > {\n  /** Number of slots. Default 6. */\n  length?: number;\n  value?: string;\n  defaultValue?: string;\n  onChange?: (value: string) => void;\n  /** Fires once every slot is filled. */\n  onComplete?: (value: string) => void;\n  /** Optional label rendered above the slots. */\n  label?: string;\n  /** Helper text shown below the slots while idle. */\n  hint?: string;\n  /** Message shown below the slots when status is \"success\". */\n  successMessage?: string;\n  /** Message shown below the slots when status is \"error\". */\n  errorMessage?: string;\n  /** External validation feedback. \"error\" shakes, \"success\" draws a check. */\n  status?: OTPStatus;\n  /** Render dots instead of the typed digits. */\n  mask?: boolean;\n  disabled?: boolean;\n  autoFocus?: boolean;\n  /** Accessible label for the underlying input. */\n  \"aria-label\"?: string;\n  /** Accessible label for the success indicator. */\n  successIndicatorLabel?: string;\n}\n\nconst EASE_OUT = [0.16, 1, 0.3, 1] as const;\n\nexport function OTPInput({\n  length = 6,\n  value: controlledValue,\n  defaultValue = \"\",\n  onChange,\n  onComplete,\n  label,\n  hint,\n  successMessage,\n  errorMessage,\n  status = \"idle\",\n  mask = false,\n  disabled = false,\n  autoFocus = false,\n  \"aria-label\": ariaLabel = \"One-time passcode\",\n  successIndicatorLabel = \"Code verified\",\n  className,\n  ...props\n}: OTPInputProps) {\n  const slotCount = normalizeLength(length);\n  const uid = React.useId();\n  const shouldReduceMotion = useReducedMotion();\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const slotsRef = React.useRef<HTMLDivElement>(null);\n  const controlled = controlledValue !== undefined;\n\n  // Source of truth is a fixed-length array, so a cleared middle slot stays an\n  // in-place hole instead of collapsing later digits to the left.\n  const [slots, setSlots] = React.useState<string[]>(() =>\n    toSlots(controlled ? controlledValue : defaultValue, slotCount),\n  );\n  const [focused, setFocused] = React.useState(false);\n  const [active, setActive] = React.useState(0);\n\n  const stateSlots = React.useMemo(\n    () => Array.from({ length: slotCount }, (_, index) => slots[index] ?? \"\"),\n    [slotCount, slots],\n  );\n  const stateJoined = stateSlots.join(\"\");\n  const controlledJoined = controlled\n    ? sanitize(controlledValue, slotCount)\n    : undefined;\n  const hasControlledOverride =\n    controlledJoined !== undefined && controlledJoined !== stateJoined;\n  const visibleSlots = hasControlledOverride\n    ? toSlots(controlledJoined, slotCount)\n    : stateSlots;\n  const complete = visibleSlots.every(isFilled);\n  const activeSlot = Math.min(\n    hasControlledOverride ? (controlledJoined ?? \"\").length : active,\n    slotCount - 1,\n  );\n\n  const commit = React.useCallback(\n    (next: string[]) => {\n      const wasComplete = visibleSlots.every(isFilled);\n      setSlots(next);\n\n      const str = next.join(\"\");\n      onChange?.(str);\n\n      // Fire only on the empty -> full transition, not every edit of a full code.\n      if (!wasComplete && next.every(isFilled)) {\n        onComplete?.(str);\n      }\n    },\n    [onChange, onComplete, visibleSlots],\n  );\n\n  const clearSlot = React.useCallback(\n    (index: number) => {\n      const next = [...visibleSlots];\n      next[index] = \"\";\n      commit(next);\n    },\n    [commit, visibleSlots],\n  );\n\n  const slotFromClientX = React.useCallback(\n    (clientX: number) => {\n      const elements = slotsRef.current?.children;\n      if (!elements) return 0;\n\n      for (let index = 0; index < elements.length; index++) {\n        if (clientX < elements[index].getBoundingClientRect().right) {\n          return index;\n        }\n      }\n\n      return slotCount - 1;\n    },\n    [slotCount],\n  );\n\n  // Single insertion path: one digit overwrites the active slot and advances; a\n  // multi-digit chunk (paste / SMS autofill) fills forward from the active slot.\n  const insert = React.useCallback(\n    (raw: string, from = activeSlot) => {\n      const digits = raw.replace(/\\D/g, \"\");\n      if (!digits) return;\n\n      const next = [...visibleSlots];\n      let index = from;\n\n      for (const digit of digits) {\n        if (index >= slotCount) break;\n        next[index] = digit;\n        index++;\n      }\n\n      commit(next);\n      setActive(Math.min(index, slotCount - 1));\n    },\n    [activeSlot, commit, slotCount, visibleSlots],\n  );\n\n  const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {\n    if (disabled || event.metaKey || event.ctrlKey || event.altKey) return;\n\n    const key = event.key;\n\n    if (/^[0-9]$/.test(key)) {\n      event.preventDefault();\n      insert(key);\n    } else if (key === \"Backspace\") {\n      event.preventDefault();\n\n      // A filled slot clears in place; an empty slot steps back and clears there.\n      if (visibleSlots[activeSlot]) {\n        clearSlot(activeSlot);\n      } else if (activeSlot > 0) {\n        clearSlot(activeSlot - 1);\n        setActive(activeSlot - 1);\n      }\n    } else if (key === \"Delete\") {\n      event.preventDefault();\n      clearSlot(activeSlot);\n    } else if (key === \"ArrowLeft\") {\n      event.preventDefault();\n      setActive((index) => Math.max(index - 1, 0));\n    } else if (key === \"ArrowRight\") {\n      event.preventDefault();\n      setActive((index) => Math.min(index + 1, slotCount - 1));\n    } else if (key === \"Home\") {\n      event.preventDefault();\n      setActive(0);\n    } else if (key === \"End\") {\n      event.preventDefault();\n      setActive(slotCount - 1);\n    }\n  };\n\n  const onPaste = (event: React.ClipboardEvent<HTMLInputElement>) => {\n    if (disabled) return;\n\n    // preventDefault suppresses duplicate native insertion; this path owns paste.\n    event.preventDefault();\n    insert(event.clipboardData.getData(\"text\"), activeSlot);\n  };\n\n  // Autofill path: SMS one-time-code arrives as a whole value in one shot.\n  // Keystrokes go through onKeyDown and paste through onPaste.\n  const onChangeNative = (event: React.ChangeEvent<HTMLInputElement>) => {\n    if (disabled) return;\n\n    const digits = sanitize(event.target.value, slotCount);\n    if (!digits) return;\n\n    commit(toSlots(digits, slotCount));\n    setActive(Math.min(digits.length, slotCount - 1));\n  };\n\n  // Error shake is imperative so it replays on every transition into \"error\".\n  React.useEffect(() => {\n    if (status !== \"error\" || shouldReduceMotion || !slotsRef.current) return;\n\n    animate(\n      slotsRef.current,\n      { x: [0, -5, 5, -3, 3, -1, 0] },\n      { duration: 0.45, ease: EASE_OUT },\n    );\n  }, [shouldReduceMotion, status]);\n\n  const showSuccess = status === \"success\";\n  const activeIndex = focused && !complete ? activeSlot : -1;\n  const message = showSuccess\n    ? successMessage\n    : status === \"error\"\n      ? errorMessage\n      : hint;\n  const hasMessageSlot = Boolean(hint || successMessage || errorMessage);\n  const messageId = hasMessageSlot ? `${uid}-message` : undefined;\n\n  return (\n    <div\n      data-slot=\"otp-input\"\n      className={cn(\"inline-flex flex-col gap-2\", className)}\n      {...props}\n    >\n      {label ? (\n        <label\n          htmlFor={`${uid}-input`}\n          className=\"text-sm font-medium text-foreground\"\n        >\n          {label}\n        </label>\n      ) : null}\n      {/* biome-ignore lint/a11y/noStaticElementInteractions: focus proxy for the real input below. */}\n      <div\n        className=\"relative inline-flex w-max\"\n        onMouseDown={(event) => {\n          if (disabled) return;\n\n          // Suppress the native click caret; we drive the active slot ourselves.\n          event.preventDefault();\n\n          const firstEmpty = visibleSlots.indexOf(\"\");\n          const cap = firstEmpty === -1 ? slotCount - 1 : firstEmpty;\n          setActive(Math.min(slotFromClientX(event.clientX), cap));\n          inputRef.current?.focus();\n        }}\n      >\n        <input\n          ref={inputRef}\n          id={`${uid}-input`}\n          inputMode=\"numeric\"\n          autoComplete=\"one-time-code\"\n          // biome-ignore lint/a11y/noAutofocus: opt-in via prop for OTP-first screens.\n          autoFocus={autoFocus}\n          disabled={disabled}\n          aria-label={ariaLabel}\n          aria-describedby={messageId}\n          aria-invalid={status === \"error\"}\n          value=\"\"\n          maxLength={slotCount}\n          onKeyDown={onKeyDown}\n          onChange={onChangeNative}\n          onPaste={onPaste}\n          onFocus={() => setFocused(true)}\n          onBlur={() => setFocused(false)}\n          className=\"absolute inset-0 z-20 h-full w-full cursor-text bg-transparent text-transparent caret-transparent opacity-0 outline-none disabled:cursor-not-allowed\"\n        />\n\n        <div ref={slotsRef} className=\"flex items-center gap-2\">\n          {Array.from({ length: slotCount }, (_, index) => {\n            const char = visibleSlots[index] ?? \"\";\n            const isActive = index === activeIndex;\n\n            return (\n              <div\n                // biome-ignore lint/suspicious/noArrayIndexKey: fixed-length slot grid, never reordered.\n                key={`${uid}-${index}`}\n                data-active={isActive}\n                data-filled={char !== \"\"}\n                className={cn(\n                  \"relative grid h-14 w-12 place-items-center overflow-hidden rounded-xl border text-xl font-semibold tabular-nums transition-colors duration-200\",\n                  showSuccess\n                    ? \"border-emerald-500/60 text-foreground\"\n                    : status === \"error\"\n                      ? \"border-destructive/60 text-foreground\"\n                      : char\n                        ? \"border-border text-foreground\"\n                        : \"border-border text-muted-foreground\",\n                  isActive &&\n                    !showSuccess &&\n                    status !== \"error\" &&\n                    \"border-foreground\",\n                  disabled && \"opacity-50\",\n                )}\n              >\n                {isActive && !showSuccess ? (\n                  <motion.span\n                    aria-hidden=\"true\"\n                    animate={\n                      shouldReduceMotion ? undefined : { opacity: [1, 1, 0, 0] }\n                    }\n                    transition={\n                      shouldReduceMotion\n                        ? undefined\n                        : {\n                            duration: 1,\n                            repeat: Number.POSITIVE_INFINITY,\n                            ease: \"linear\",\n                          }\n                    }\n                    className={cn(\n                      \"pointer-events-none absolute top-1/2 h-6 w-px -translate-y-1/2 bg-foreground\",\n                      char ? \"right-3\" : \"left-1/2 -translate-x-1/2\",\n                    )}\n                  />\n                ) : null}\n\n                <AnimatePresence initial={false}>\n                  {char ? (\n                    <motion.span\n                      key={char}\n                      initial={\n                        shouldReduceMotion\n                          ? { opacity: 0 }\n                          : { y: 14, opacity: 0, filter: \"blur(4px)\" }\n                      }\n                      animate={\n                        shouldReduceMotion\n                          ? { opacity: 1 }\n                          : { y: 0, opacity: 1, filter: \"blur(0px)\" }\n                      }\n                      exit={\n                        shouldReduceMotion\n                          ? { opacity: 0 }\n                          : { y: -14, opacity: 0, filter: \"blur(4px)\" }\n                      }\n                      transition={\n                        shouldReduceMotion\n                          ? { duration: 0 }\n                          : { duration: 0.22, ease: EASE_OUT }\n                      }\n                      className=\"absolute inset-0 grid place-items-center leading-none\"\n                    >\n                      {mask ? \"•\" : char}\n                    </motion.span>\n                  ) : null}\n                </AnimatePresence>\n              </div>\n            );\n          })}\n        </div>\n\n        {showSuccess ? (\n          <CheckAnimation\n            variant=\"circle\"\n            size=\"md\"\n            label={successIndicatorLabel}\n            className=\"pointer-events-none absolute left-full top-1/2 ml-2 -translate-y-1/2 text-emerald-500 [--check-animation-check-duration:260ms] [--check-animation-shape-duration:420ms]\"\n          />\n        ) : null}\n      </div>\n\n      {hasMessageSlot ? (\n        <p\n          id={messageId}\n          aria-live=\"polite\"\n          aria-hidden={message ? undefined : true}\n          className={cn(\n            \"min-h-5 text-sm\",\n            showSuccess\n              ? \"text-muted-foreground\"\n              : status === \"error\"\n                ? \"text-destructive\"\n                : \"text-muted-foreground\",\n          )}\n        >\n          {message ?? \"\\u00a0\"}\n        </p>\n      ) : null}\n    </div>\n  );\n}\n\nfunction isFilled(value: string) {\n  return value !== \"\";\n}\n\nfunction normalizeLength(length: number) {\n  if (!Number.isFinite(length)) return 6;\n\n  return Math.max(1, Math.floor(length));\n}\n\nfunction sanitize(raw: string | undefined, length: number) {\n  return (raw ?? \"\").replace(/\\D/g, \"\").slice(0, length);\n}\n\nfunction toSlots(raw: string | undefined, length: number) {\n  const digits = sanitize(raw, length);\n\n  return Array.from({ length }, (_, index) => digits[index] ?? \"\");\n}\n",
      "type": "registry:ui",
      "target": "components/ui/otp-input.tsx"
    }
  ],
  "meta": {
    "tags": [
      "otp",
      "one-time-code",
      "passcode",
      "paste",
      "autofill"
    ],
    "effects": [
      "digit-roll",
      "error-shake",
      "svg-draw",
      "success-state"
    ]
  },
  "categories": [
    "form",
    "input",
    "feedback"
  ],
  "type": "registry:ui"
}