{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "otp-input",
  "type": "registry:ui",
  "title": "Animated OTP Input",
  "description": "One-time-code entry with paste, SMS autofill, keyboard navigation and an error shake.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/otp-input.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useAnimate, useReducedMotion } from \"motion/react\";\n\nexport interface OtpInputProps extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"onChange\" | \"defaultValue\"> {\n  /** Number of characters in the code. */\n  length?: number;\n  /** Controlled value. */\n  value?: string;\n  /** Initial value when uncontrolled. */\n  defaultValue?: string;\n  /** Called with the current code on every change. */\n  onChange?: (value: string) => void;\n  /** Called once every slot is filled. */\n  onComplete?: (value: string) => void;\n  /** numeric: digits only (numeric keypad) · alphanumeric: A–Z and 0–9, upper-cased. */\n  mode?: \"numeric\" | \"alphanumeric\";\n  /** Error state. Truthy values mark the inputs invalid and shake the row; a node is also shown. */\n  error?: React.ReactNode;\n  /** Group label, announced by screen readers. */\n  label?: string;\n  /** Visually hide the label (it stays available to assistive tech). */\n  hideLabel?: boolean;\n  /** Render dots instead of characters. */\n  mask?: boolean;\n  /** Show a divider in the middle of even-length codes. */\n  separator?: boolean;\n  size?: \"md\" | \"lg\";\n  /** Focus colour (any CSS colour). */\n  accent?: string;\n  disabled?: boolean;\n  /** Focus the first slot on mount. */\n  autoFocus?: boolean;\n  /** When set, a hidden input submits the code with native forms. */\n  name?: string;\n}\n\nconst PATTERNS = {\n  numeric: /[^0-9]/g,\n  alphanumeric: /[^A-Z0-9]/g,\n};\n\nconst sanitize = (text: string, mode: \"numeric\" | \"alphanumeric\") => text.toUpperCase().replace(PATTERNS[mode], \"\");\n\nconst toSlots = (text: string, length: number) => Array.from({ length }, (_, i) => text[i] ?? \"\");\n\nconst SIZES = {\n  md: { slot: \"h-14 w-11 rounded-xl text-[22px]\", gap: \"gap-2\", caret: \"h-6\" },\n  lg: { slot: \"h-16 w-[52px] rounded-2xl text-[26px]\", gap: \"gap-2.5\", caret: \"h-7\" },\n};\n\n/**\n * One-time-code entry as a group of single-character inputs. Handles typing, paste and\n * SMS autofill (distributing characters across slots), arrow / Home / End navigation and\n * Backspace that clears then moves back. Characters pop in, the caret blinks, and errors shake.\n */\nexport function OtpInput({\n  length = 6,\n  value,\n  defaultValue = \"\",\n  onChange,\n  onComplete,\n  mode = \"numeric\",\n  error,\n  label = \"Verification code\",\n  hideLabel = false,\n  mask = false,\n  separator = true,\n  size = \"md\",\n  accent = \"#FF7448\",\n  disabled = false,\n  autoFocus = false,\n  name,\n  className = \"\",\n  style,\n  ...props\n}: OtpInputProps) {\n  const count = Math.max(1, Math.min(12, Math.round(length)));\n  const reduceMotion = useReducedMotion();\n  const uid = React.useId();\n  const labelId = `${uid}-label`;\n  const errorId = `${uid}-error`;\n  const inputs = React.useRef<(HTMLInputElement | null)[]>([]);\n  const [scope, animate] = useAnimate<HTMLDivElement>();\n\n  // Slots are kept as an array so a cleared middle slot keeps its position.\n  const [chars, setChars] = React.useState(() => toSlots(sanitize(value ?? defaultValue, mode), count));\n  // Re-sync when a controlled value changes from outside (store-previous-prop pattern).\n  const [syncedValue, setSyncedValue] = React.useState(value);\n  if (value !== undefined && value !== syncedValue) {\n    setSyncedValue(value);\n    if (value !== chars.join(\"\")) setChars(toSlots(sanitize(value, mode), count));\n  }\n  const slots = Array.from({ length: count }, (_, i) => chars[i] ?? \"\");\n\n  const [focused, setFocused] = React.useState<number | null>(null);\n  const invalid = Boolean(error);\n  const errorText = invalid && error !== true ? error : null;\n  const firstEmpty = slots.findIndex((c) => !c);\n\n  // Shake the row whenever the field becomes invalid.\n  React.useEffect(() => {\n    if (!invalid || reduceMotion || !scope.current) return;\n    animate(scope.current, { x: [0, -9, 8, -6, 4, -2, 0] }, { duration: 0.42, ease: \"easeOut\" });\n  }, [invalid, reduceMotion, animate, scope]);\n\n  const focusSlot = (i: number) => {\n    const el = inputs.current[Math.max(0, Math.min(count - 1, i))];\n    if (el) {\n      el.focus();\n      el.select();\n    }\n  };\n\n  const commit = (next: string[]) => {\n    setChars(next);\n    const code = next.join(\"\");\n    onChange?.(code);\n    if (next.every(Boolean) && next.join(\"\") !== slots.join(\"\")) onComplete?.(code);\n  };\n\n  /** Writes `text` into the slots starting at `start` and moves focus after it. */\n  const distribute = (text: string, start: number) => {\n    const clean = sanitize(text, mode);\n    if (!clean) return;\n    const from = clean.length >= count ? 0 : start;\n    const next = [...slots];\n    for (let k = 0; k < clean.length && from + k < count; k++) next[from + k] = clean[k];\n    commit(next);\n    focusSlot(Math.min(from + clean.length, count - 1));\n  };\n\n  const handleChange = (i: number, event: React.ChangeEvent<HTMLInputElement>) => {\n    const raw = event.target.value;\n    let clean = sanitize(raw, mode);\n    // One extra character next to the existing one: the caret wasn't selecting it, so keep\n    // only what was typed. Longer inserts (autofill, IME) are distributed below.\n    if (slots[i] && clean.length === 2 && clean.includes(slots[i])) clean = clean.replace(slots[i], \"\");\n    if (!clean) {\n      if (!raw) {\n        const next = [...slots];\n        next[i] = \"\";\n        commit(next);\n      }\n      return;\n    }\n    if (clean.length > 1) {\n      // Autofill (e.g. iOS one-time-code) or a multi-character insert.\n      distribute(clean, i);\n      return;\n    }\n    const next = [...slots];\n    next[i] = clean;\n    commit(next);\n    if (i < count - 1) focusSlot(i + 1);\n  };\n\n  const handleKeyDown = (i: number, event: React.KeyboardEvent<HTMLInputElement>) => {\n    switch (event.key) {\n      case \"Backspace\": {\n        event.preventDefault();\n        const next = [...slots];\n        if (next[i]) {\n          next[i] = \"\";\n          commit(next);\n        } else if (i > 0) {\n          next[i - 1] = \"\";\n          commit(next);\n          focusSlot(i - 1);\n        }\n        break;\n      }\n      case \"Delete\": {\n        event.preventDefault();\n        if (slots[i]) {\n          const next = [...slots];\n          next[i] = \"\";\n          commit(next);\n        }\n        break;\n      }\n      case \"ArrowLeft\":\n        event.preventDefault();\n        focusSlot(i - 1);\n        break;\n      case \"ArrowRight\":\n        event.preventDefault();\n        focusSlot(i + 1);\n        break;\n      case \"Home\":\n        event.preventDefault();\n        focusSlot(0);\n        break;\n      case \"End\":\n        event.preventDefault();\n        focusSlot(firstEmpty === -1 ? count - 1 : firstEmpty);\n        break;\n      default:\n        // Re-typing the same character produces no change event; still advance.\n        if (\n          event.key.length === 1 &&\n          !event.metaKey &&\n          !event.ctrlKey &&\n          !event.altKey &&\n          slots[i] &&\n          sanitize(event.key, mode) === slots[i] &&\n          i < count - 1\n        ) {\n          event.preventDefault();\n          focusSlot(i + 1);\n        }\n    }\n  };\n\n  const half = separator && count >= 4 && count % 2 === 0 ? count / 2 : -1;\n  const dims = SIZES[size];\n  const code = slots.join(\"\");\n\n  return (\n    <div\n      role=\"group\"\n      aria-labelledby={labelId}\n      className={`inline-flex flex-col items-start ${className}`}\n      style={{ [\"--otp-accent\" as string]: accent, ...style }}\n      {...props}\n    >\n      <span\n        id={labelId}\n        className={hideLabel ? \"sr-only\" : \"mb-2.5 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\"}\n      >\n        {label}\n      </span>\n\n      <div ref={scope} className={`flex items-center ${dims.gap}`}>\n        {slots.map((char, i) => {\n          const isFocused = focused === i;\n          return (\n            <React.Fragment key={i}>\n              {i === half && (\n                <span aria-hidden=\"true\" className=\"mx-1 h-px w-3 rounded-full bg-zinc-900/25 dark:bg-white/25\" />\n              )}\n              <div\n                className={`relative grid shrink-0 place-items-center border bg-white transition-[border-color,box-shadow,background-color] duration-150 dark:bg-zinc-950 motion-reduce:transition-none ${dims.slot} ${\n                  invalid\n                    ? \"border-rose-500/80 dark:border-rose-400/80\"\n                    : isFocused\n                      ? \"border-[var(--otp-accent)] shadow-[0_0_0_3px_color-mix(in_srgb,var(--otp-accent)_22%,transparent)]\"\n                      : char\n                        ? \"border-zinc-900/30 dark:border-white/30\"\n                        : \"border-zinc-900/12 dark:border-white/12\"\n                } ${disabled ? \"opacity-50\" : \"\"}`}\n              >\n                <input\n                  ref={(el) => {\n                    inputs.current[i] = el;\n                  }}\n                  type={mask ? \"password\" : \"text\"}\n                  inputMode={mode === \"numeric\" ? \"numeric\" : \"text\"}\n                  pattern={mode === \"numeric\" ? \"[0-9]*\" : undefined}\n                  autoComplete={i === 0 ? \"one-time-code\" : \"off\"}\n                  autoCapitalize={mode === \"alphanumeric\" ? \"characters\" : \"off\"}\n                  autoCorrect=\"off\"\n                  spellCheck={false}\n                  enterKeyHint={i === count - 1 ? \"done\" : \"next\"}\n                  aria-label={`${mode === \"numeric\" ? \"Digit\" : \"Character\"} ${i + 1} of ${count}`}\n                  aria-invalid={invalid || undefined}\n                  aria-describedby={errorText ? errorId : undefined}\n                  disabled={disabled}\n                  autoFocus={autoFocus && i === 0}\n                  value={char}\n                  onChange={(e) => handleChange(i, e)}\n                  onKeyDown={(e) => handleKeyDown(i, e)}\n                  onPaste={(e) => {\n                    e.preventDefault();\n                    distribute(e.clipboardData.getData(\"text\"), i);\n                  }}\n                  onFocus={(e) => {\n                    setFocused(i);\n                    e.currentTarget.select();\n                  }}\n                  onBlur={() => setFocused((f) => (f === i ? null : f))}\n                  onMouseDown={(e) => {\n                    // Clicking past the first empty slot lands on the first empty slot.\n                    if (firstEmpty !== -1 && i > firstEmpty) {\n                      e.preventDefault();\n                      focusSlot(firstEmpty);\n                    }\n                  }}\n                  // The real input is invisible; the glyph below is its visual twin.\n                  className=\"absolute inset-0 h-full w-full cursor-text rounded-[inherit] bg-transparent text-center text-transparent caret-transparent outline-none selection:bg-transparent disabled:cursor-not-allowed\"\n                />\n                <AnimatePresence initial={false}>\n                  {char ? (\n                    <motion.span\n                      key={char + i}\n                      aria-hidden=\"true\"\n                      className=\"pointer-events-none font-medium tabular-nums tracking-tight text-zinc-950 dark:text-white\"\n                      initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.7 }}\n                      animate={{ opacity: 1, y: 0, scale: 1 }}\n                      exit={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}\n                      transition={reduceMotion ? { duration: 0 } : { type: \"spring\", stiffness: 520, damping: 26, mass: 0.6 }}\n                      style={{ gridArea: \"1 / 1\" }}\n                    >\n                      {mask ? <span className=\"block size-2.5 rounded-full bg-current\" /> : char}\n                    </motion.span>\n                  ) : isFocused ? (\n                    <motion.span\n                      key=\"caret\"\n                      aria-hidden=\"true\"\n                      className={`pointer-events-none w-[2px] rounded-full bg-[var(--otp-accent)] ${dims.caret}`}\n                      initial={{ opacity: 1 }}\n                      animate={reduceMotion ? { opacity: 1 } : { opacity: [1, 1, 0, 0] }}\n                      transition={reduceMotion ? { duration: 0 } : { duration: 1, times: [0, 0.5, 0.55, 1], repeat: Infinity }}\n                      style={{ gridArea: \"1 / 1\" }}\n                    />\n                  ) : null}\n                </AnimatePresence>\n              </div>\n            </React.Fragment>\n          );\n        })}\n      </div>\n\n      {/* w-0 + min-w-full: the message wraps to the row's width instead of widening the group. */}\n      <div aria-live=\"polite\" className=\"w-0 min-w-full\">\n        {errorText && (\n          <p id={errorId} className=\"mt-2.5 text-[13px] font-medium leading-snug text-rose-600 dark:text-rose-400\">\n            {errorText}\n          </p>\n        )}\n      </div>\n\n      {name && <input type=\"hidden\" name={name} value={code} />}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "forms"
  ],
  "docs": "Documentation: https://wancraft.my/components/otp-input"
}
