{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "otp-input",
  "title": "OTP Input",
  "description": "A one-time-code input whose characters roll into place behind a caret that slides from slot to slot.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "components/ui/otp-input.tsx",
      "content": "\"use client\";\n\nimport { useRef, useState, type ComponentProps } from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\nconst PATTERNS = {\n  numbers: /^[0-9]$/,\n  letters: /^[a-zA-Z]$/,\n  both: /^[a-zA-Z0-9]$/,\n} as const;\n\n// success draws its own ring in svg, so no css ring here\nconst RING = {\n  idle: \"focus-visible:ring-2 focus-visible:ring-[#868593]/50\",\n  success: \"\",\n  error: \"ring-2 ring-[#FF3B30]/70 delay-150\",\n} as const;\n\nconst SUCCESS = \"#34C759\";\n\nconst SIZES = {\n  sm: {\n    box: \"size-10 rounded-lg\",\n    text: \"text-base\",\n    caret: \"h-5\",\n    gap: \"gap-1.5\",\n    px: 40,\n    radius: 8,\n  },\n  md: {\n    box: \"size-12 rounded-xl\",\n    text: \"text-lg\",\n    caret: \"h-6\",\n    gap: \"gap-2\",\n    px: 48,\n    radius: 12,\n  },\n  lg: {\n    box: \"size-14 rounded-2xl\",\n    text: \"text-xl\",\n    caret: \"h-7\",\n    gap: \"gap-2.5\",\n    px: 56,\n    radius: 16,\n  },\n} as const;\n\nconst SLOT_CLASS =\n  \"bg-[#F4F4F9] dark:bg-[#262626] text-center font-medium text-transparent caret-transparent outline-none transition-shadow duration-200 selection:bg-transparent disabled:cursor-not-allowed disabled:opacity-50\";\n\nconst ROLL_SPRING = { type: \"spring\", stiffness: 500, damping: 34 } as const;\nconst CARET_SPRING = { type: \"spring\", stiffness: 500, damping: 40 } as const;\nconst BLINK = {\n  duration: 1.1,\n  times: [0, 0.5, 0.5, 1],\n  repeat: Infinity,\n  ease: \"linear\" as const,\n};\n\nconst ROLL = {\n  initial: { y: \"110%\" },\n  exit: (cleared: boolean) => ({ y: cleared ? \"110%\" : \"-110%\" }),\n};\n\nconst SHAKE = [0, -5, 4, -2, 0];\n\nconst toSlots = (code: string, length: number) =>\n  Array.from({ length }, (_, i) => code[i] ?? \"\");\n\nexport type OtpStatus = \"idle\" | \"success\" | \"error\";\n\nexport type OtpInputProps = Omit<\n  ComponentProps<\"div\">,\n  \"onChange\" | \"value\" | \"defaultValue\"\n> & {\n  length?: number;\n  value?: string;\n  defaultValue?: string;\n  onChange?: (value: string) => void;\n  onComplete?: (value: string) => void;\n  type?: keyof typeof PATTERNS;\n  size?: keyof typeof SIZES;\n  status?: OtpStatus;\n  mask?: boolean;\n  disabled?: boolean;\n  autoFocus?: boolean;\n  slotClassName?: string;\n};\n\nexport function OtpInput({\n  length = 6,\n  value,\n  defaultValue = \"\",\n  onChange,\n  onComplete,\n  type = \"numbers\",\n  size = \"md\",\n  status = \"idle\",\n  mask = false,\n  disabled,\n  autoFocus,\n  className,\n  slotClassName,\n  ...props\n}: OtpInputProps) {\n  const [uncontrolled, setUncontrolled] = useState(() =>\n    toSlots(defaultValue, length),\n  );\n  const [cleared, setCleared] = useState(false);\n  const [focused, setFocused] = useState<number | null>(null);\n  const [caretX, setCaretX] = useState(0);\n  const inputs = useRef<(HTMLInputElement | null)[]>([]);\n  const cells = useRef<(HTMLDivElement | null)[]>([]);\n  // the slot the user deliberately moved to, so a full code only changes on purpose\n  const editingAt = useRef<number | null>(null);\n  const reduceMotion = useReducedMotion();\n\n  // padded, not joined: joining would close a gap left by a mid-code backspace\n  const slots =\n    value === undefined\n      ? Array.from({ length }, (_, i) => uncontrolled[i] ?? \"\")\n      : toSlots(value, length);\n  const numeric = type === \"numbers\";\n  const scale = SIZES[size];\n  const caretVisible = focused !== null && !slots[focused];\n\n  const commit = (next: string[]) => {\n    if (value === undefined) setUncontrolled(next);\n    const code = next.join(\"\");\n    onChange?.(code);\n    if (next.every(Boolean)) onComplete?.(code);\n  };\n\n  const setCharAt = (index: number, char: string) => {\n    setCleared(!char);\n    commit(slots.map((slot, i) => (i === index ? char : slot)));\n  };\n\n  const focusAt = (index: number) => {\n    const input = inputs.current[Math.min(Math.max(index, 0), length - 1)];\n    input?.focus();\n    input?.select();\n  };\n\n  const fill = (index: number, chars: string[]) => {\n    const room = Math.min(chars.length, length - index);\n    const next = [...slots];\n    chars.slice(0, room).forEach((char, i) => {\n      next[index + i] = char;\n    });\n    setCleared(false);\n    commit(next);\n    editingAt.current = null;\n    focusAt(index + room);\n  };\n\n  const handleChange = (index: number, raw: string) => {\n    const chars = raw.split(\"\").filter((char) => PATTERNS[type].test(char));\n    if (!chars.length) return;\n\n    // typing into a filled slot appends, so keep only the new character\n    const typed =\n      chars.length === 1\n        ? chars[0]\n        : chars.length === 2 && chars[0] === slots[index]\n          ? chars[1]\n          : null;\n\n    if (typed === null) {\n      // anything longer arrived at once: a paste or an SMS autofill\n      fill(index, chars);\n      return;\n    }\n\n    if (slots.every(Boolean) && editingAt.current !== index) return;\n\n    setCharAt(index, typed);\n    editingAt.current = null;\n    focusAt(index + 1);\n  };\n\n  const handleKeyDown = (\n    index: number,\n    event: React.KeyboardEvent<HTMLInputElement>,\n  ) => {\n    const actions: Record<string, () => void> = {\n      ArrowLeft: () => {\n        editingAt.current = Math.max(index - 1, 0);\n        focusAt(index - 1);\n      },\n      ArrowRight: () => {\n        editingAt.current = Math.min(index + 1, length - 1);\n        focusAt(index + 1);\n      },\n      Backspace: () => {\n        if (slots[index]) {\n          setCharAt(index, \"\");\n        } else if (index > 0) {\n          setCharAt(index - 1, \"\");\n          focusAt(index - 1);\n        }\n      },\n    };\n\n    const action = actions[event.key];\n    if (!action) return;\n    event.preventDefault();\n    action();\n  };\n\n  const handlePaste = (\n    index: number,\n    event: React.ClipboardEvent<HTMLInputElement>,\n  ) => {\n    event.preventDefault();\n    const pasted = event.clipboardData\n      .getData(\"text\")\n      .split(\"\")\n      .filter((char) => PATTERNS[type].test(char));\n    if (pasted.length) fill(index, pasted);\n  };\n\n  // clicking past the first gap lands on the gap, so a code stays contiguous\n  const handlePointerDown = (\n    index: number,\n    event: React.PointerEvent<HTMLInputElement>,\n  ) => {\n    const firstEmpty = slots.findIndex((slot) => !slot);\n    const target = firstEmpty === -1 ? index : Math.min(index, firstEmpty);\n    editingAt.current = target;\n    if (target === index) return;\n    event.preventDefault();\n    focusAt(target);\n  };\n\n  return (\n    <div\n      data-slot=\"otp-input\"\n      data-status={status}\n      className={cn(\"relative inline-flex\", className)}\n      {...props}\n    >\n      <motion.div\n        onFocus={(event) => {\n          const index = inputs.current.indexOf(\n            event.target as HTMLInputElement,\n          );\n          const cell = cells.current[index];\n          setFocused(index);\n          if (cell) setCaretX(cell.offsetLeft + cell.offsetWidth / 2);\n        }}\n        onBlur={(event) => {\n          if (!event.currentTarget.contains(event.relatedTarget as Node)) {\n            setFocused(null);\n          }\n        }}\n        animate={{\n          x: status === \"error\" && !reduceMotion ? SHAKE : 0,\n        }}\n        transition={{ duration: 0.32, ease: \"easeOut\" }}\n        data-slot=\"otp-input-row\"\n        className={cn(\"relative flex items-center\", scale.gap)}\n      >\n        {slots.map((slot, index) => (\n          <div\n            key={index}\n            ref={(el) => {\n              cells.current[index] = el;\n            }}\n            data-slot=\"otp-input-cell\"\n            data-filled={Boolean(slot)}\n            className=\"relative\"\n          >\n            <input\n              ref={(el) => {\n                inputs.current[index] = el;\n              }}\n              data-slot=\"otp-input-slot\"\n              data-filled={Boolean(slot)}\n              value={slot}\n              onChange={(event) => handleChange(index, event.target.value)}\n              onKeyDown={(event) => handleKeyDown(index, event)}\n              onPaste={(event) => handlePaste(index, event)}\n              onPointerDown={(event) => handlePointerDown(index, event)}\n              onFocus={(event) => event.target.select()}\n              type={mask ? \"password\" : \"text\"}\n              inputMode={numeric ? \"numeric\" : \"text\"}\n              autoCapitalize={numeric ? undefined : \"characters\"}\n              autoComplete={index === 0 ? \"one-time-code\" : \"off\"}\n              autoFocus={autoFocus && index === 0}\n              disabled={disabled}\n              aria-label={`${numeric ? \"Digit\" : \"Character\"} ${index + 1} of ${length}`}\n              className={cn(\n                SLOT_CLASS,\n                scale.box,\n                scale.text,\n                RING[status],\n                slotClassName,\n              )}\n            />\n\n            <AnimatePresence>\n              {status === \"success\" && (\n                <motion.svg\n                  aria-hidden\n                  data-slot=\"otp-input-ring\"\n                  viewBox={`0 0 ${scale.px} ${scale.px}`}\n                  exit={{ opacity: 0 }}\n                  transition={{ duration: 0.15 }}\n                  className=\"pointer-events-none absolute inset-0 size-full\"\n                >\n                  <motion.rect\n                    x={1}\n                    y={1}\n                    width={scale.px - 2}\n                    height={scale.px - 2}\n                    rx={scale.radius - 1}\n                    fill=\"none\"\n                    stroke={SUCCESS}\n                    strokeWidth={2}\n                    initial={reduceMotion ? false : { pathLength: 0 }}\n                    animate={{ pathLength: 1 }}\n                    transition={\n                      reduceMotion\n                        ? { duration: 0 }\n                        : {\n                            duration: 0.45,\n                            ease: \"easeOut\",\n                            delay: 0.15 + index * 0.05,\n                          }\n                    }\n                  />\n                </motion.svg>\n              )}\n            </AnimatePresence>\n\n            <span className=\"pointer-events-none absolute inset-0 grid place-items-center overflow-hidden\">\n              <AnimatePresence initial={false} custom={cleared}>\n                {slot && (\n                  <motion.span\n                    key={slot}\n                    custom={cleared}\n                    variants={ROLL}\n                    initial={reduceMotion ? false : \"initial\"}\n                    animate={{ y: 0 }}\n                    exit={reduceMotion ? { opacity: 0 } : \"exit\"}\n                    transition={reduceMotion ? { duration: 0 } : ROLL_SPRING}\n                    data-slot=\"otp-input-char\"\n                    className={cn(\n                      \"font-semibold text-black dark:text-white\",\n                      scale.text,\n                    )}\n                  >\n                    {mask ? \"•\" : slot}\n                  </motion.span>\n                )}\n              </AnimatePresence>\n            </span>\n          </div>\n        ))}\n\n        {caretVisible && (\n          <motion.span\n            aria-hidden\n            data-slot=\"otp-input-caret\"\n            initial={false}\n            animate={{ x: caretX - 1, y: \"-50%\", opacity: [1, 1, 0, 0] }}\n            transition={{\n              x: reduceMotion ? { duration: 0 } : CARET_SPRING,\n              opacity: BLINK,\n            }}\n            className={cn(\n              \"pointer-events-none absolute left-0 top-1/2 w-0.5 rounded-full bg-black dark:bg-white\",\n              scale.caret,\n            )}\n          />\n        )}\n      </motion.div>\n    </div>\n  );\n}\n\nexport default OtpInput;\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}