{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "input-otp",
  "title": "Input OTP",
  "description": "One-time code entry drawn as boxes but built on one real input, so SMS autofill, paste, backspace and screen readers all work.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://miseui.junsu4621.workers.dev/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/miseui/ui/input-otp.tsx",
      "content": "import type * as React from \"react\"\nimport { useEffect, useImperativeHandle } from \"react\"\nimport { useAnimate, motion, useReducedMotion } from \"motion/react\"\nimport { OtpBox, type InputOTPVariant } from \"@/registry/miseui/ui/input-otp-box\"\nimport { useOtpHandlers, useOtpState, type InputOTPMode } from \"@/registry/miseui/ui/input-otp-state\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\ntype InputOTPEffect = \"pop\"\n\nconst DEFAULT_LENGTH = 6\nconst SHAKE_X_PX = [0, -6, 6, -4, 4, 0]\nconst SHAKE_SECONDS = 0.32\n\nconst ROW_CLASS: Record<InputOTPVariant, string> = {\n  boxes: \"gap-2\",\n  joined: \"\",\n  underline: \"gap-2\",\n  \"dotted-mask\": \"gap-1\",\n}\n\n/** Plays once per error; a repeat needs the owner to clear `error` first, which is what a retry does anyway. */\nfunction useShakeOnError(invalid: boolean, reduceMotion: boolean) {\n  const [scope, animate] = useAnimate<HTMLDivElement>()\n  useEffect(() => {\n    if (!invalid || reduceMotion || !scope.current) return\n    animate(scope.current, { x: SHAKE_X_PX }, { duration: SHAKE_SECONDS, ease: \"easeOut\" })\n  }, [invalid, reduceMotion, animate, scope])\n  return scope\n}\n\ninterface OtpRowProps {\n  length: number\n  variant: InputOTPVariant\n  text: string\n  focused: boolean\n  selection: readonly [number, number]\n  invalid: boolean\n  pop: boolean\n  reduceMotion: boolean\n}\n\nfunction OtpRow({ length, variant, text, focused, selection, invalid, pop, reduceMotion }: OtpRowProps) {\n  const [start, end] = selection\n  const collapsed = start === end\n  return (\n    <div aria-hidden=\"true\" className={cn(\"flex w-full\", ROW_CLASS[variant])}>\n      {Array.from({ length }, (_, index) => {\n        const active = focused && (collapsed ? index === start : index >= start && index < end)\n        return (\n          <OtpBox\n            key={index}\n            variant={variant}\n            char={text[index]}\n            active={active}\n            caret={active && collapsed}\n            invalid={invalid}\n            pop={pop}\n            reduceMotion={reduceMotion}\n          />\n        )\n      })}\n    </div>\n  )\n}\n\ntype InputOTPProps = Omit<React.ComponentProps<\"input\">, \"value\" | \"defaultValue\" | \"onChange\" | \"type\" | \"size\"> & {\n  /** How many characters the code has. */\n  length?: number\n  value?: string\n  defaultValue?: string\n  onValueChange?: (value: string) => void\n  /** Fires when the last character lands, so a form can submit itself. */\n  onComplete?: (value: string) => void\n  /** `numeric` opens the number pad; `alphanumeric` accepts letters too and upper-cases them. */\n  mode?: InputOTPMode\n  /** `boxes` apart, `joined` as one bar, `underline` only a bottom edge, `dotted-mask` hides digits behind dots. */\n  variant?: InputOTPVariant\n  /** `pop` springs each character in as it lands. */\n  effect?: InputOTPEffect\n  /** Marks the code wrong: red edge, aria-invalid and a short shake that collapses under reduced motion. */\n  error?: boolean\n}\n\nfunction InputOTP({\n  length = DEFAULT_LENGTH,\n  value,\n  defaultValue,\n  onValueChange,\n  onComplete,\n  mode = \"numeric\",\n  variant = \"boxes\",\n  effect,\n  error = false,\n  className,\n  ref,\n  ...props\n}: InputOTPProps) {\n  const reduceMotion = useReducedMotion() ?? false\n  const invalid = error || props[\"aria-invalid\"] === true || props[\"aria-invalid\"] === \"true\"\n  const otp = useOtpState({ length, mode, value, defaultValue, onValueChange, onComplete })\n  const { inputRef, text, focused, selection } = otp\n  const handlers = useOtpHandlers(otp)\n  const scope = useShakeOnError(invalid, reduceMotion)\n  useImperativeHandle(ref, () => inputRef.current as HTMLInputElement, [inputRef])\n  const row = { length, variant, invalid, reduceMotion, focused, text, selection }\n  return (\n    <motion.div\n      ref={scope}\n      data-slot=\"input-otp\"\n      data-variant={variant}\n      data-effect={effect}\n      data-error={invalid ? \"true\" : undefined}\n      className={cn(\"relative inline-flex max-w-full has-disabled:opacity-50\", className)}\n    >\n      <OtpRow {...row} pop={effect === \"pop\" && !reduceMotion} />\n      {/* Sits over the boxes and stays invisible: clicks, long-press paste and autofill all reach the real field. */}\n      <input\n        {...props}\n        {...handlers}\n        ref={inputRef}\n        type=\"text\"\n        value={text}\n        maxLength={length}\n        inputMode={mode === \"numeric\" ? \"numeric\" : \"text\"}\n        autoComplete=\"one-time-code\"\n        spellCheck={false}\n        aria-invalid={invalid ? true : undefined}\n        className=\"absolute inset-0 size-full cursor-text text-base opacity-0 outline-none disabled:cursor-not-allowed\"\n      />\n    </motion.div>\n  )\n}\n\nexport { InputOTP, type InputOTPProps, type InputOTPVariant, type InputOTPEffect, type InputOTPMode }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/input-otp-box.tsx",
      "content": "import { motion } from \"motion/react\"\nimport { SPRING_DEFAULT } from \"@/registry/miseui/lib/motion\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\ntype InputOTPVariant = \"boxes\" | \"joined\" | \"underline\" | \"dotted-mask\"\n\nconst CARET_BLINK_SECONDS = 1.1\n\n/** flex-1 from a 40 px basis: six boxes fit a 320 px screen, and shrink rather than overflow anything narrower. */\nconst BOX_BASE_CLASS =\n  \"relative flex h-12 min-w-0 flex-1 basis-10 items-center justify-center transition-[border-color,box-shadow] duration-(--duration-fast) ease-(--ease-mise)\"\n\nconst BOX_CLASS: Record<InputOTPVariant, string> = {\n  boxes:\n    \"rounded-md border border-input shadow-xs data-[active=true]:border-ring data-[active=true]:ring-[3px] data-[active=true]:ring-ring/50 data-[invalid=true]:border-destructive\",\n  // The active box rises above its neighbours, or the shared edge would cover half of its ring.\n  joined:\n    \"-ml-px border border-input shadow-xs first:ml-0 first:rounded-l-md last:rounded-r-md data-[active=true]:z-10 data-[active=true]:border-ring data-[active=true]:ring-[3px] data-[active=true]:ring-ring/50 data-[invalid=true]:border-destructive\",\n  underline: \"border-b-2 border-input data-[active=true]:border-mise data-[invalid=true]:border-destructive\",\n  \"dotted-mask\": \"\",\n}\n\nfunction OtpCaret({ reduceMotion }: { reduceMotion: boolean }) {\n  return (\n    <motion.span\n      data-slot=\"input-otp-caret\"\n      className=\"absolute h-6 w-px bg-foreground\"\n      animate={reduceMotion ? { opacity: 1 } : { opacity: [1, 1, 0, 0] }}\n      transition={reduceMotion ? { duration: 0 } : { duration: CARET_BLINK_SECONDS, repeat: Infinity, ease: \"linear\" }}\n    />\n  )\n}\n\nfunction OtpDigit({ char, pop }: { char: string; pop: boolean }) {\n  const className = \"text-lg leading-none font-medium tabular-nums\"\n  if (!pop) {\n    return (\n      <span data-slot=\"input-otp-digit\" className={className}>\n        {char}\n      </span>\n    )\n  }\n  return (\n    <motion.span\n      data-slot=\"input-otp-digit\"\n      className={className}\n      initial={{ scale: 0.5, opacity: 0 }}\n      animate={{ scale: 1, opacity: 1 }}\n      transition={SPRING_DEFAULT}\n    >\n      {char}\n    </motion.span>\n  )\n}\n\nfunction OtpDot({ filled, invalid, pop }: { filled: boolean; invalid: boolean; pop: boolean }) {\n  const colour = invalid ? \"bg-destructive\" : \"bg-foreground\"\n  return (\n    <motion.span\n      // Remounts on change, so a digit pops in while an emptied slot just goes quiet.\n      key={filled ? \"filled\" : \"empty\"}\n      data-slot=\"input-otp-dot\"\n      data-filled={filled ? \"true\" : \"false\"}\n      className={cn(\"size-2.5 rounded-full\", filled ? colour : \"bg-input\")}\n      initial={pop && filled ? { scale: 0.4 } : false}\n      animate={{ scale: 1 }}\n      transition={SPRING_DEFAULT}\n    />\n  )\n}\n\ninterface OtpBoxProps {\n  variant: InputOTPVariant\n  char: string | undefined\n  active: boolean\n  caret: boolean\n  invalid: boolean\n  pop: boolean\n  reduceMotion: boolean\n}\n\nfunction OtpBox({ variant, char, active, caret, invalid, pop, reduceMotion }: OtpBoxProps) {\n  const masked = variant === \"dotted-mask\"\n  return (\n    <span\n      data-slot=\"input-otp-box\"\n      data-active={active ? \"true\" : \"false\"}\n      data-invalid={invalid ? \"true\" : \"false\"}\n      className={cn(BOX_BASE_CLASS, BOX_CLASS[variant])}\n    >\n      {masked ? <OtpDot filled={char !== undefined} invalid={invalid} pop={pop} /> : null}\n      {!masked && char !== undefined ? <OtpDigit key={char} char={char} pop={pop} /> : null}\n      {caret && !masked ? <OtpCaret reduceMotion={reduceMotion} /> : null}\n      {/* Dots have no glyph to blink beside, and a filled dot being overwritten needs marking too, so a ring does both. */}\n      {active && masked ? (\n        <span data-slot=\"input-otp-caret\" className=\"absolute size-5 rounded-full border-2 border-mise\" />\n      ) : null}\n    </span>\n  )\n}\n\nexport { OtpBox, type InputOTPVariant }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/input-otp-state.ts",
      "content": "import type * as React from \"react\"\nimport { useRef, useState } from \"react\"\n\ntype InputOTPMode = \"numeric\" | \"alphanumeric\"\ntype Selection = readonly [number, number]\n\nconst STRIP: Record<InputOTPMode, RegExp> = { numeric: /\\D/g, alphanumeric: /[^a-zA-Z0-9]/g }\n\nfunction sanitize(raw: string, mode: InputOTPMode) {\n  const kept = raw.replace(STRIP[mode], \"\")\n  return mode === \"alphanumeric\" ? kept.toUpperCase() : kept\n}\n\n/**\n * The boxes behave as overwrite slots: a caret resting before a digit selects that digit, so typing replaces it\n * and arrows move by whole boxes. Past the last box the caret falls back onto it, so a full code stays editable.\n */\nfunction boxSelection(start: number, end: number, filled: number, length: number): Selection {\n  if (start !== end) return [start, end]\n  if (start >= length) return [length - 1, length]\n  return start < filled ? [start, start + 1] : [start, start]\n}\n\ntype KeyAction = { place: number } | { remove: Selection; caret: number } | null\n\n/** Android keyboards often report \"Unidentified\" here; those edits fall through to native editing and onChange. */\nfunction actionForKey(key: string, [start, end]: Selection, filled: number): KeyAction {\n  if (key === \"ArrowLeft\") return { place: Math.max(0, start - 1) }\n  if (key === \"ArrowRight\") return { place: Math.min(filled, start + 1) }\n  if (key === \"Home\") return { place: 0 }\n  if (key === \"End\") return { place: filled }\n  if (key === \"Delete\") return { remove: [start, end], caret: start }\n  if (key !== \"Backspace\") return null\n  if (start === end) return start > 0 ? { remove: [start - 1, start], caret: start - 1 } : { place: 0 }\n  // Off the last digit the caret stays on the emptied box; mid-code it steps left, or repeated presses would stall.\n  const stay = end >= filled || end - start > 1\n  return { remove: [start, end], caret: stay ? start : Math.max(0, start - 1) }\n}\n\ninterface OtpStateOptions {\n  length: number\n  mode: InputOTPMode\n  value?: string\n  defaultValue?: string\n  onValueChange?: (value: string) => void\n  onComplete?: (value: string) => void\n}\n\n/** All edits go through the one real input, so autofill, undo, IME and screen readers see an ordinary text field. */\nfunction useOtpState({ length, mode, value, defaultValue, onValueChange, onComplete }: OtpStateOptions) {\n  const inputRef = useRef<HTMLInputElement>(null)\n  const [uncontrolled, setUncontrolled] = useState(() => sanitize(defaultValue ?? \"\", mode).slice(0, length))\n  const [storedSelection, setSelection] = useState<Selection>([0, 0])\n  const [focused, setFocused] = useState(false)\n  const current = value ?? uncontrolled\n  // An owner clearing a wrong code fires no `select` event, so a selection past the end is stale and is re-derived.\n  const selection =\n    storedSelection[1] > current.length\n      ? boxSelection(current.length, current.length, current.length, length)\n      : storedSelection\n\n  const place = (start: number, end: number, text: string) => {\n    const next = boxSelection(start, end, text.length, length)\n    inputRef.current?.setSelectionRange(next[0], next[1])\n    // `select` fires often and usually changes nothing; keeping the old array skips those re-renders.\n    setSelection((previous) => (previous[0] === next[0] && previous[1] === next[1] ? previous : next))\n  }\n\n  const commit = (next: string, caret: number) => {\n    const text = next.slice(0, length)\n    const at = Math.min(caret, text.length)\n    // Writing to the DOM before React re-renders keeps the caret where it is put, not at the end of the field.\n    if (inputRef.current) inputRef.current.value = text\n    place(at, at, text)\n    if (text === current) return\n    setUncontrolled(text)\n    onValueChange?.(text)\n    if (text.length === length) onComplete?.(text)\n  }\n\n  return { inputRef, text: current, selection, focused, setFocused, place, commit, length, mode }\n}\n\n/** Separate from the state so each handler reads on its own. Named as a hook because it closes over a ref. */\nfunction useOtpHandlers(otp: ReturnType<typeof useOtpState>) {\n  const { text: current, selection, setFocused, place, commit, length, mode } = otp\n  const placeAt = (index: number) => place(index, index, current)\n  const remove = ([start, end]: Selection, caret: number) => commit(current.slice(0, start) + current.slice(end), caret)\n\n  return {\n    onFocus: () => {\n      setFocused(true)\n      // Tabbing in selects all by default; the caret belongs on the next empty box.\n      placeAt(current.length)\n    },\n    onBlur: () => setFocused(false),\n    onChange: (event: React.ChangeEvent<HTMLInputElement>) => {\n      const raw = event.target.value\n      const caret = event.target.selectionStart ?? raw.length\n      // Count what the filter removes before the caret, or a rejected character would push the caret forward.\n      commit(sanitize(raw, mode), sanitize(raw.slice(0, caret), mode).length)\n    },\n    onPaste: (event: React.ClipboardEvent<HTMLInputElement>) => {\n      event.preventDefault()\n      const pasted = sanitize(event.clipboardData.getData(\"text\"), mode)\n      if (!pasted) return\n      // A code as long as the field is the whole answer, whatever box the caret happens to be on.\n      if (pasted.length >= length) return commit(pasted, length)\n      const [start, end] = selection\n      commit(current.slice(0, start) + pasted + current.slice(end), start + pasted.length)\n    },\n    onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => {\n      if (event.metaKey || event.ctrlKey || event.altKey) return\n      const action = actionForKey(event.key, selection, current.length)\n      if (!action) return\n      event.preventDefault()\n      if (\"place\" in action) placeAt(action.place)\n      else remove(action.remove, action.caret)\n    },\n    onSelect: (event: React.SyntheticEvent<HTMLInputElement>) => {\n      const { selectionStart, selectionEnd, value } = event.currentTarget\n      place(selectionStart ?? 0, selectionEnd ?? 0, value)\n    },\n    onClick: (event: React.MouseEvent<HTMLInputElement>) => {\n      const rect = event.currentTarget.getBoundingClientRect()\n      if (rect.width === 0) return\n      const box = Math.floor(((event.clientX - rect.left) / rect.width) * length)\n      placeAt(Math.min(Math.max(0, box), current.length))\n    },\n  }\n}\n\nexport { useOtpHandlers, useOtpState, type InputOTPMode }\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "forms"
  ],
  "type": "registry:ui"
}