{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"otp-input","type":"registry:block","title":"OTP Input","description":"One-time-code input with a gliding focus ring, digits that roll in per slot, error shake and a success check draw.","author":"Saurabh <saurabh10102@gmail.com>","dependencies":["clsx","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/otp-input.tsx","type":"registry:component","target":"@components/motion/otp-input.tsx","content":"\"use client\";\n\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useReducedMotion,\n} from \"motion/react\";\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type OTPStatus = \"idle\" | \"error\" | \"success\";\n\nexport interface OTPInputProps {\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  className?: string;\n}\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  className,\n}: OTPInputProps) {\n  const uid = useId();\n  const reduce = useReducedMotion();\n  const inputRef = useRef<HTMLInputElement>(null);\n  const slotsRef = useRef<HTMLDivElement>(null);\n\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 the digits after it to the left.\n  const [slots, setSlots] = useState<string[]>(() =>\n    toSlots(controlled ? controlledValue : defaultValue, length),\n  );\n  const [focused, setFocused] = useState(false);\n  const [active, setActive] = useState(0);\n\n  const joined = slots.join(\"\");\n  const joinedRef = useRef(joined);\n  joinedRef.current = joined;\n\n  // Pull in external value changes; skip when the parent is just echoing our own\n  // onChange, so internal holes survive the controlled round-trip.\n  useEffect(() => {\n    if (!controlled) return;\n    const incoming = sanitize(controlledValue, length);\n    if (incoming !== joinedRef.current) setSlots(toSlots(incoming, length));\n  }, [controlled, controlledValue, length]);\n\n  const commit = (next: string[]) => {\n    const wasComplete = slots.every((c) => c !== \"\");\n    setSlots(next);\n    const str = next.join(\"\");\n    onChange?.(str);\n    // Fire only on the empty→full transition, not on every edit of a full code.\n    if (!wasComplete && next.every((c) => c !== \"\")) onComplete?.(str);\n  };\n\n  const clearSlot = (idx: number) => {\n    const next = [...slots];\n    next[idx] = \"\";\n    commit(next);\n  };\n\n  const slotFromClientX = (clientX: number) => {\n    const els = slotsRef.current?.children;\n    if (!els) return 0;\n    for (let i = 0; i < els.length; i++) {\n      if (clientX < els[i].getBoundingClientRect().right) return i;\n    }\n    return length - 1;\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 = (raw: string, from = active) => {\n    const digits = raw.replace(/\\D/g, \"\");\n    if (!digits) return;\n    const next = [...slots];\n    let i = from;\n    for (const ch of digits) {\n      if (i >= length) break;\n      next[i] = ch;\n      i++;\n    }\n    commit(next);\n    setActive(Math.min(i, length - 1));\n  };\n\n  // The keyboard is the single source of truth: preventDefault on keydown\n  // reliably blocks native insertion (unlike beforeinput in React), so the hidden\n  // input never accumulates a competing string and slot holes survive.\n  const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {\n    if (disabled || e.metaKey || e.ctrlKey || e.altKey) return;\n    const k = e.key;\n    if (/^[0-9]$/.test(k)) {\n      e.preventDefault();\n      insert(k);\n    } else if (k === \"Backspace\") {\n      e.preventDefault();\n      // A filled slot clears in place; an empty slot steps back and clears there.\n      if (slots[active]) {\n        clearSlot(active);\n      } else if (active > 0) {\n        clearSlot(active - 1);\n        setActive(active - 1);\n      }\n    } else if (k === \"Delete\") {\n      e.preventDefault();\n      clearSlot(active);\n    } else if (k === \"ArrowLeft\") {\n      e.preventDefault();\n      setActive((a) => Math.max(a - 1, 0));\n    } else if (k === \"ArrowRight\") {\n      e.preventDefault();\n      setActive((a) => Math.min(a + 1, length - 1));\n    } else if (k === \"Home\") {\n      e.preventDefault();\n      setActive(0);\n    } else if (k === \"End\") {\n      e.preventDefault();\n      setActive(length - 1);\n    }\n  };\n\n  const onPaste = (e: React.ClipboardEvent<HTMLInputElement>) => {\n    if (disabled) return;\n    // preventDefault suppresses the duplicate onChange, keeping that path autofill-only.\n    e.preventDefault();\n    insert(e.clipboardData.getData(\"text\"), active);\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, so only autofill\n  // reaches here — spread it across the slots from the start.\n  const onChangeNative = (e: React.ChangeEvent<HTMLInputElement>) => {\n    const digits = sanitize(e.target.value, length);\n    if (!digits) return;\n    commit(toSlots(digits, length));\n    setActive(Math.min(digits.length, length - 1));\n  };\n\n  // Error shake — imperative so it replays on every transition into \"error\".\n  useEffect(() => {\n    if (status !== \"error\" || reduce || !slotsRef.current) return;\n    animate(\n      slotsRef.current,\n      { x: [0, -5, 5, -3, 3, -1, 0] },\n      { duration: 0.45, ease: EASE_OUT },\n    );\n  }, [status, reduce]);\n\n  const showSuccess = status === \"success\";\n  const activeIndex = focused ? active : -1;\n  const message = showSuccess\n    ? successMessage\n    : status === \"error\"\n      ? errorMessage\n      : hint;\n\n  return (\n    <div className={cn(\"inline-flex flex-col gap-2\", className)}>\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={(e) => {\n          if (disabled) return;\n          // Suppress the native click-caret; we drive the active slot ourselves.\n          e.preventDefault();\n          // Clamp to the first empty slot so a click can't jump ahead of progress.\n          const firstEmpty = slots.indexOf(\"\");\n          const cap = firstEmpty === -1 ? length - 1 : firstEmpty;\n          setActive(Math.min(slotFromClientX(e.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-invalid={status === \"error\"}\n          // Kept empty on purpose — our state owns the digits, native holds none.\n          value=\"\"\n          maxLength={length}\n          onKeyDown={onKeyDown}\n          onChange={onChangeNative}\n          onPaste={onPaste}\n          onFocus={() => setFocused(true)}\n          onBlur={() => setFocused(false)}\n          // Transparent overlay owns focus, the soft keyboard, paste and autofill;\n          // the slots below are purely presentational.\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 }, (_, i) => {\n            const char = slots[i] ?? \"\";\n            const isActive = i === activeIndex;\n            return (\n              <div\n                // biome-ignore lint/suspicious/noArrayIndexKey: fixed-length slot grid, never reordered.\n                key={`${uid}-${i}`}\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-strong text-foreground\"\n                        : \"border-border text-muted-foreground\",\n                  // Active slot reads stronger; twMerge lets this win the border.\n                  isActive && !showSuccess && status !== \"error\" && \"border-foreground\",\n                  disabled && \"opacity-50\",\n                )}\n              >\n                {/* Blinking caret marks the active slot — centered when empty,\n                    trailing the digit when the slot is already filled. */}\n                {isActive && !showSuccess ? (\n                  <motion.span\n                    aria-hidden\n                    animate={reduce ? undefined : { opacity: [1, 1, 0, 0] }}\n                    transition={\n                      reduce\n                        ? undefined\n                        : { duration: 1, repeat: Number.POSITIVE_INFINITY, ease: \"linear\" }\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                {/* Digits roll vertically. Each is absolutely centered so enter and\n                    exit overlap in place — no in-flow reflow, no sideways drift. */}\n                <AnimatePresence initial={false}>\n                  {char ? (\n                    <motion.span\n                      key={char}\n                      initial={\n                        reduce\n                          ? { opacity: 0 }\n                          : { y: 14, opacity: 0, filter: \"blur(4px)\" }\n                      }\n                      animate={\n                        reduce\n                          ? { opacity: 1 }\n                          : { y: 0, opacity: 1, filter: \"blur(0px)\" }\n                      }\n                      exit={\n                        reduce\n                          ? { opacity: 0 }\n                          : { y: -14, opacity: 0, filter: \"blur(4px)\" }\n                      }\n                      transition={\n                        reduce\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        <AnimatePresence>\n          {showSuccess ? (\n            <motion.span\n              initial={reduce ? { opacity: 0 } : { scale: 0.6, opacity: 0 }}\n              animate={reduce ? { opacity: 1 } : { scale: 1, opacity: 1 }}\n              exit={reduce ? { opacity: 0 } : { scale: 0.6, opacity: 0 }}\n              transition={\n                reduce\n                  ? { duration: 0 }\n                  : { type: \"spring\", stiffness: 500, damping: 28 }\n              }\n              className=\"pointer-events-none absolute -right-7 top-1/2 -translate-y-1/2 text-emerald-500\"\n              aria-hidden\n            >\n              <svg\n                width=\"20\"\n                height=\"20\"\n                viewBox=\"0 0 24 24\"\n                fill=\"none\"\n                stroke=\"currentColor\"\n                strokeWidth={3}\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n              >\n                <title>Verified</title>\n                <motion.path\n                  d=\"M5 13l4 4L19 7\"\n                  initial={reduce ? { pathLength: 1 } : { pathLength: 0 }}\n                  animate={{ pathLength: 1 }}\n                  transition={\n                    reduce\n                      ? { duration: 0 }\n                      : { duration: 0.35, ease: EASE_OUT, delay: 0.1 }\n                  }\n                />\n              </svg>\n            </motion.span>\n          ) : null}\n        </AnimatePresence>\n      </div>\n\n      {message ? (\n        <p\n          aria-live=\"polite\"\n          className={cn(\n            \"text-sm\",\n            showSuccess\n              ? \"text-emerald-500\"\n              : status === \"error\"\n                ? \"text-destructive\"\n                : \"text-muted-foreground\",\n          )}\n        >\n          {message}\n        </p>\n      ) : null}\n    </div>\n  );\n}\n\nfunction sanitize(raw: string, length: number) {\n  return raw.replace(/\\D/g, \"\").slice(0, length);\n}\n\nfunction toSlots(raw: string, length: number) {\n  const digits = sanitize(raw, length);\n  return Array.from({ length }, (_, i) => digits[i] ?? \"\");\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}