{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"otp-input","type":"registry:component","title":"OTP input","description":"Six slots mirror one real input, so paste and SMS autofill just work.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/otp-input.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { motion } from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\ntype Status = \"idle\" | \"checking\" | \"error\" | \"success\";\n\nconst ICON_SWAP = { type: \"spring\", duration: 0.3, bounce: 0 } as const;\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\n// Long enough to register the red and the shake, short enough that retyping\n// never feels blocked.\nconst ERROR_HOLD = 700;\nconst SUCCESS_HOLD = 2000;\n// Slot geometry, in px: w-10 slots with gap-2 between them.\nconst GAP = 8;\nconst SLOT_STEP = 40 + GAP;\n\n// Keyframes suit both: the caret loops forever and the shake is a one-shot\n// that nothing interrupts.\nconst CSS = `\n@keyframes otp-caret {\n  0%, 50% { opacity: 1; }\n  50.01%, 100% { opacity: 0; }\n}\n@keyframes otp-shake {\n  0% { translate: 0; }\n  20% { translate: -6px; }\n  40% { translate: 5px; }\n  60% { translate: -3px; }\n  80% { translate: 1.5px; }\n  100% { translate: 0; }\n}\n.otp-caret { animation: otp-caret 1s infinite; }\n.otp-shake { animation: otp-shake 300ms cubic-bezier(0.23, 1, 0.32, 1); }\n@media (prefers-reduced-motion: reduce) {\n  .otp-caret, .otp-shake { animation: none; }\n}\n`;\n\nexport function OtpInput({\n  length = 6,\n  onVerify,\n  hint,\n  label = \"One-time code\",\n  autoFocus = false,\n  className,\n}: {\n  length?: number;\n  onVerify: (code: string) => boolean | Promise<boolean>;\n  hint?: string;\n  label?: string;\n  autoFocus?: boolean;\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion();\n  const hintId = useId();\n  const inputRef = useRef<HTMLInputElement>(null);\n  const timer = useRef<ReturnType<typeof setTimeout>>(undefined);\n  const attempt = useRef(0);\n  const [value, setValue] = useState(\"\");\n  const [focused, setFocused] = useState(false);\n  const [status, setStatus] = useState<Status>(\"idle\");\n\n  useEffect(() => {\n    const attempts = attempt;\n    return () => {\n      clearTimeout(timer.current);\n      // Drops any verification that resolves after unmount.\n      attempts.current++;\n    };\n  }, []);\n\n  // A code screen exists to take the code, so typing should work straight\n  // away, and a digit typed after focus has wandered off to nothing brings\n  // it back instead of being lost. Keys meant for another field are left\n  // alone.\n  useEffect(() => {\n    // Inert copies, like the index preview, never take focus or keys.\n    if (!autoFocus || inputRef.current?.closest(\"[inert]\")) return;\n    inputRef.current?.focus({ preventScroll: true });\n    const onKeyDown = (e: KeyboardEvent) => {\n      if (!/^\\d$/.test(e.key) || e.metaKey || e.ctrlKey || e.altKey) return;\n      const current = document.activeElement;\n      if (current && current !== document.body) return;\n      inputRef.current?.focus({ preventScroll: true });\n    };\n    document.addEventListener(\"keydown\", onKeyDown);\n    return () => document.removeEventListener(\"keydown\", onKeyDown);\n  }, [autoFocus]);\n\n  const reset = () => {\n    setValue(\"\");\n    setStatus(\"idle\");\n    inputRef.current?.focus();\n  };\n\n  const verify = async (code: string) => {\n    const id = ++attempt.current;\n    setStatus(\"checking\");\n    let ok = false;\n    try {\n      ok = await onVerify(code);\n    } catch {\n      // A failed check reads as a wrong code rather than a stuck input.\n    }\n    if (id !== attempt.current) return;\n    setStatus(ok ? \"success\" : \"error\");\n    clearTimeout(timer.current);\n    timer.current = setTimeout(reset, ok ? SUCCESS_HOLD : ERROR_HOLD);\n  };\n\n  const onChange = (raw: string) => {\n    const next = raw.replace(/\\D/g, \"\").slice(0, length);\n    setValue(next);\n    if (next.length === length) verify(next);\n  };\n\n  // Slots only fill left to right, so a click anywhere lands the caret at\n  // the end. A real selection (like select all) is left alone.\n  const keepCaretAtEnd = () => {\n    const input = inputRef.current;\n    if (!input || input.selectionStart !== input.selectionEnd) return;\n    const end = input.value.length;\n    if (input.selectionStart !== end) input.setSelectionRange(end, end);\n  };\n\n  const locked = status !== \"idle\";\n  const active = focused && !locked ? Math.min(value.length, length - 1) : -1;\n\n  return (\n    <div className={cn(\"flex flex-col items-center gap-3\", className)}>\n      <style href=\"otp-input\" precedence=\"default\">\n        {CSS}\n      </style>\n      {/* The one real input sits over the slots with invisible text, so\n          paste, SMS autofill, selection and backspace all stay native. */}\n      <div\n        className={cn(\"relative flex gap-2\", status === \"error\" && \"otp-shake\")}\n      >\n        <input\n          ref={inputRef}\n          value={value}\n          onChange={(e) => onChange(e.target.value)}\n          onFocus={() => {\n            setFocused(true);\n            // The browser places the caret after focus fires.\n            requestAnimationFrame(keepCaretAtEnd);\n          }}\n          onBlur={() => setFocused(false)}\n          onSelect={keepCaretAtEnd}\n          readOnly={locked}\n          type=\"text\"\n          inputMode=\"numeric\"\n          autoComplete=\"one-time-code\"\n          pattern=\"\\d*\"\n          maxLength={length}\n          aria-label={label}\n          aria-describedby={hint ? hintId : undefined}\n          aria-invalid={status === \"error\"}\n          spellCheck={false}\n          // 16px text stops iOS from zooming the page on focus.\n          className=\"absolute inset-0 z-10 cursor-text bg-transparent text-base text-transparent caret-transparent outline-hidden selection:bg-transparent\"\n        />\n        {Array.from({ length }, (_, i) => (\n          <Slot\n            key={i}\n            char={value[i]}\n            caret={i === active && value.length < length}\n            caretKey={value.length}\n            status={status}\n            // Distance to the row's middle, in slots: how far this one\n            // travels when the code seals.\n            offset={(length - 1) / 2 - i}\n            edge={i === 0 ? \"start\" : i === length - 1 ? \"end\" : \"middle\"}\n            reduceMotion={reduceMotion}\n          />\n        ))}\n        {/* One focus ring for the whole code, gliding to the next slot as\n            each digit lands, so the eye is led along instead of watching\n            one box switch off and the next switch on. */}\n        <span\n          aria-hidden\n          style={{ translate: `${Math.max(active, 0) * SLOT_STEP}px 0` }}\n          className={cn(\n            \"pointer-events-none absolute top-0 left-0 h-12 w-10 rounded-lg border border-foreground ring-1 ring-foreground\",\n            \"transition-[translate,opacity,scale] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-[opacity]\",\n            active < 0 && \"scale-[0.96] opacity-0\",\n          )}\n        />\n        {/* The seal's single outline, drawn around where the slots end up,\n            so it has no seams where the six boxes meet. It waits until the\n            320ms close is nearly done, or the slots would overhang it. */}\n        <span\n          aria-hidden\n          style={{ insetInline: reduceMotion ? 0 : ((length - 1) * GAP) / 2 }}\n          className={cn(\n            \"pointer-events-none absolute inset-y-0 rounded-lg border border-foreground\",\n            status === \"success\"\n              ? \"opacity-100 transition-[opacity] delay-[240ms] duration-200 ease-out\"\n              : \"opacity-0 transition-[opacity] duration-150 ease-out\",\n          )}\n        />\n      </div>\n\n      {/* All three messages share one grid cell, so swapping never shifts\n          anything and the cell keeps the width of the longest. */}\n      <p className=\"grid h-5 text-sm\">\n        <Message visible={status === \"idle\" || status === \"checking\"}>\n          <span id={hintId} className=\"text-muted-foreground\">\n            {hint}\n          </span>\n        </Message>\n        <Message visible={status === \"error\"}>\n          <span className=\"text-destructive\">Wrong code, try again</span>\n        </Message>\n        <Message visible={status === \"success\"}>\n          <motion.svg\n            aria-hidden\n            viewBox=\"0 0 16 16\"\n            className=\"size-4 shrink-0 text-foreground\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth={1.5}\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            initial={false}\n            animate={\n              status === \"success\"\n                ? { scale: 1, opacity: 1, filter: \"blur(0px)\" }\n                : reduceMotion\n                  ? { scale: 1, opacity: 0, filter: \"blur(0px)\" }\n                  : { scale: 0.25, opacity: 0, filter: \"blur(4px)\" }\n            }\n            transition={ICON_SWAP}\n          >\n            <path d=\"m3.5 8.5 3 3 6-7\" />\n          </motion.svg>\n          <span className=\"text-foreground\">Verified</span>\n        </Message>\n      </p>\n\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {status === \"success\"\n          ? \"Code verified\"\n          : status === \"error\"\n            ? \"Wrong code. The field was cleared, try again.\"\n            : \"\"}\n      </span>\n    </div>\n  );\n}\n\nfunction Message({\n  visible,\n  children,\n}: {\n  visible: boolean;\n  children: React.ReactNode;\n}) {\n  return (\n    <span\n      aria-hidden={!visible}\n      className={cn(\n        \"col-start-1 row-start-1 flex items-center justify-center gap-1.5 whitespace-nowrap\",\n        \"transition-[opacity,filter] ease-out motion-reduce:transition-[opacity]\",\n        // Enters in 200ms, leaves in 120ms so the old line never lingers.\n        visible\n          ? \"opacity-100 duration-200\"\n          : \"opacity-0 blur-[2px] duration-[120ms] motion-reduce:filter-none\",\n      )}\n    >\n      {children}\n    </span>\n  );\n}\n\nfunction Slot({\n  char,\n  caret,\n  caretKey,\n  status,\n  offset,\n  edge,\n  reduceMotion,\n}: {\n  char: string | undefined;\n  caret: boolean;\n  caretKey: number;\n  status: Status;\n  offset: number;\n  edge: \"start\" | \"middle\" | \"end\";\n  reduceMotion: boolean | null;\n}) {\n  // A verified code seals: the slots slide together into one bar, their\n  // inner walls and corners dissolve, and the six boxes become one field.\n  const sealed = status === \"success\";\n  return (\n    <div\n      aria-hidden\n      style={{\n        translate: sealed && !reduceMotion ? `${offset * GAP}px 0` : undefined,\n      }}\n      className={cn(\n        \"flex h-12 w-10 items-center justify-center rounded-lg border bg-muted font-mono text-lg text-foreground tabular-nums\",\n        sealed\n          ? // 320ms, over the usual cap: it plays once per code, as the\n            // payoff, and a faster close reads as a glitch rather than a seal.\n            \"border-transparent transition-[translate,border-color,border-radius,color] duration-[320ms] ease-[cubic-bezier(0.77,0,0.175,1)]\"\n          : \"transition-[translate,border-color,border-radius,color] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]\",\n        // The walls between digits stay as hairlines, so the code still\n        // reads in groups of one.\n        sealed && edge !== \"start\" && \"rounded-l-none border-l-border\",\n        sealed && edge !== \"end\" && \"rounded-r-none\",\n        status === \"error\"\n          ? \"border-destructive text-destructive\"\n          : !sealed && \"border-border\",\n      )}\n    >\n      {char ? (\n        // Keyed by the digit so a replaced digit lifts in again. Deleting\n        // unmounts it, so removals vanish with no exit.\n        <motion.span\n          key={char}\n          initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 4 }}\n          animate={{ opacity: 1, y: 0 }}\n          transition={{ duration: 0.15, ease: EASE_OUT }}\n        >\n          {char}\n        </motion.span>\n      ) : caret ? (\n        // Remounts on every keystroke so the caret restarts solid while\n        // typing, like a native caret.\n        <span key={caretKey} className=\"otp-caret h-5 w-px bg-foreground\" />\n      ) : null}\n    </div>\n  );\n}\n\nexport default function OtpInputDemo() {\n  return (\n    <OtpInput\n      autoFocus\n      hint=\"Try 123456\"\n      onVerify={(code) => code === \"123456\"}\n    />\n  );\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"categories":["inputs"],"docs":"From ui lab: https://lab.xevrion.dev/lab/otp-input"}