"use client"; import { AnimatePresence, animate, motion, useReducedMotion, } from "motion/react"; import { useEffect, useId, useRef, useState } from "react"; import { EASE_OUT } from "@/lib/ease"; import { cn } from "@/lib/utils"; export type OTPStatus = "idle" | "error" | "success"; export interface OTPInputProps { /** Number of slots. Default 6. */ length?: number; value?: string; defaultValue?: string; onChange?: (value: string) => void; /** Fires once every slot is filled. */ onComplete?: (value: string) => void; /** Optional label rendered above the slots. */ label?: string; /** Helper text shown below the slots while idle. */ hint?: string; /** Message shown below the slots when status is "success". */ successMessage?: string; /** Message shown below the slots when status is "error". */ errorMessage?: string; /** External validation feedback. "error" shakes, "success" draws a check. */ status?: OTPStatus; /** Render dots instead of the typed digits. */ mask?: boolean; disabled?: boolean; autoFocus?: boolean; /** Accessible label for the underlying input. */ "aria-label"?: string; className?: string; } export function OTPInput({ length = 6, value: controlledValue, defaultValue = "", onChange, onComplete, label, hint, successMessage, errorMessage, status = "idle", mask = false, disabled = false, autoFocus = false, "aria-label": ariaLabel = "One-time passcode", className, }: OTPInputProps) { const uid = useId(); const reduce = useReducedMotion(); const inputRef = useRef(null); const slotsRef = useRef(null); const controlled = controlledValue !== undefined; // Source of truth is a fixed-length array, so a cleared middle slot stays an // in-place hole instead of collapsing the digits after it to the left. const [slots, setSlots] = useState(() => toSlots(controlled ? controlledValue : defaultValue, length), ); const [focused, setFocused] = useState(false); const [active, setActive] = useState(0); const joined = slots.join(""); const joinedRef = useRef(joined); joinedRef.current = joined; // Pull in external value changes; skip when the parent is just echoing our own // onChange, so internal holes survive the controlled round-trip. useEffect(() => { if (!controlled) return; const incoming = sanitize(controlledValue, length); if (incoming !== joinedRef.current) setSlots(toSlots(incoming, length)); }, [controlled, controlledValue, length]); const commit = (next: string[]) => { const wasComplete = slots.every((c) => c !== ""); setSlots(next); const str = next.join(""); onChange?.(str); // Fire only on the empty→full transition, not on every edit of a full code. if (!wasComplete && next.every((c) => c !== "")) onComplete?.(str); }; const clearSlot = (idx: number) => { const next = [...slots]; next[idx] = ""; commit(next); }; const slotFromClientX = (clientX: number) => { const els = slotsRef.current?.children; if (!els) return 0; for (let i = 0; i < els.length; i++) { if (clientX < els[i].getBoundingClientRect().right) return i; } return length - 1; }; // Single insertion path: one digit overwrites the active slot and advances; a // multi-digit chunk (paste / SMS autofill) fills forward from the active slot. const insert = (raw: string, from = active) => { const digits = raw.replace(/\D/g, ""); if (!digits) return; const next = [...slots]; let i = from; for (const ch of digits) { if (i >= length) break; next[i] = ch; i++; } commit(next); setActive(Math.min(i, length - 1)); }; // The keyboard is the single source of truth: preventDefault on keydown // reliably blocks native insertion (unlike beforeinput in React), so the hidden // input never accumulates a competing string and slot holes survive. const onKeyDown = (e: React.KeyboardEvent) => { if (disabled || e.metaKey || e.ctrlKey || e.altKey) return; const k = e.key; if (/^[0-9]$/.test(k)) { e.preventDefault(); insert(k); } else if (k === "Backspace") { e.preventDefault(); // A filled slot clears in place; an empty slot steps back and clears there. if (slots[active]) { clearSlot(active); } else if (active > 0) { clearSlot(active - 1); setActive(active - 1); } } else if (k === "Delete") { e.preventDefault(); clearSlot(active); } else if (k === "ArrowLeft") { e.preventDefault(); setActive((a) => Math.max(a - 1, 0)); } else if (k === "ArrowRight") { e.preventDefault(); setActive((a) => Math.min(a + 1, length - 1)); } else if (k === "Home") { e.preventDefault(); setActive(0); } else if (k === "End") { e.preventDefault(); setActive(length - 1); } }; const onPaste = (e: React.ClipboardEvent) => { if (disabled) return; // preventDefault suppresses the duplicate onChange, keeping that path autofill-only. e.preventDefault(); insert(e.clipboardData.getData("text"), active); }; // Autofill path: SMS one-time-code arrives as a whole value in one shot. // Keystrokes go through onKeyDown and paste through onPaste, so only autofill // reaches here — spread it across the slots from the start. const onChangeNative = (e: React.ChangeEvent) => { const digits = sanitize(e.target.value, length); if (!digits) return; commit(toSlots(digits, length)); setActive(Math.min(digits.length, length - 1)); }; // Error shake — imperative so it replays on every transition into "error". useEffect(() => { if (status !== "error" || reduce || !slotsRef.current) return; animate( slotsRef.current, { x: [0, -5, 5, -3, 3, -1, 0] }, { duration: 0.45, ease: EASE_OUT }, ); }, [status, reduce]); const showSuccess = status === "success"; const activeIndex = focused ? active : -1; const message = showSuccess ? successMessage : status === "error" ? errorMessage : hint; return (
{label ? ( ) : null} {/* biome-ignore lint/a11y/noStaticElementInteractions: focus proxy for the real input below. */}
{ if (disabled) return; // Suppress the native click-caret; we drive the active slot ourselves. e.preventDefault(); // Clamp to the first empty slot so a click can't jump ahead of progress. const firstEmpty = slots.indexOf(""); const cap = firstEmpty === -1 ? length - 1 : firstEmpty; setActive(Math.min(slotFromClientX(e.clientX), cap)); inputRef.current?.focus(); }} > setFocused(true)} onBlur={() => setFocused(false)} // Transparent overlay owns focus, the soft keyboard, paste and autofill; // the slots below are purely presentational. 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" />
{Array.from({ length }, (_, i) => { const char = slots[i] ?? ""; const isActive = i === activeIndex; return (
{/* Blinking caret marks the active slot — centered when empty, trailing the digit when the slot is already filled. */} {isActive && !showSuccess ? ( ) : null} {/* Digits roll vertically. Each is absolutely centered so enter and exit overlap in place — no in-flow reflow, no sideways drift. */} {char ? ( {mask ? "•" : char} ) : null}
); })}
{showSuccess ? ( Verified ) : null}
{message ? (

{message}

) : null}
); } function sanitize(raw: string, length: number) { return raw.replace(/\D/g, "").slice(0, length); } function toSlots(raw: string, length: number) { const digits = sanitize(raw, length); return Array.from({ length }, (_, i) => digits[i] ?? ""); }