"use client"; import React, { useRef, useState, useId, useEffect, type ComponentProps, type KeyboardEvent, type ClipboardEvent, type PointerEvent, } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { cn } from "@/lib/utils"; const PATTERNS = { numbers: /^[0-9]$/, letters: /^[a-zA-Z]$/, both: /^[a-zA-Z0-9]$/, } as const; export type OtpStatus = "idle" | "success" | "error" | "loading"; export type OtpSize = "sm" | "md" | "lg" | "xl"; export type OtpVariant = "default" | "glass" | "neon" | "underlined"; export interface OtpInputProps extends Omit< ComponentProps<"div">, "onChange" | "value" | "defaultValue" > { length?: number; value?: string; defaultValue?: string; onChange?: (value: string) => void; onComplete?: (value: string) => void; type?: keyof typeof PATTERNS; size?: OtpSize; variant?: OtpVariant; status?: OtpStatus; mask?: boolean; disabled?: boolean; autoFocus?: boolean; separator?: React.ReactNode; groupSize?: number; slotClassName?: string; } const SIZES = { sm: { box: "size-10 rounded-lg", text: "text-base", caret: "h-5", caretHeight: 20, gap: "gap-2", px: 40, radius: 8, }, md: { box: "size-12 rounded-xl", text: "text-lg", caret: "h-6", caretHeight: 24, gap: "gap-2.5", px: 48, radius: 12, }, lg: { box: "size-14 rounded-2xl", text: "text-xl", caret: "h-7", caretHeight: 28, gap: "gap-3", px: 56, radius: 16, }, xl: { box: "size-16 rounded-2xl", text: "text-2xl font-semibold", caret: "h-8", caretHeight: 32, gap: "gap-3.5", px: 64, radius: 16, }, } as const; const SIZE_SPRING = { type: "spring", stiffness: 380, damping: 28, mass: 0.7, } as const; const ROLL_SPRING = { type: "spring", stiffness: 520, damping: 30, mass: 0.75, } as const; const CARET_SPRING = { type: "spring", stiffness: 600, damping: 36, mass: 0.6, } as const; const SHAKE_KEYFRAMES = [0, -12, 10, -8, 6, -3, 0]; const toSlots = (code: string, length: number) => Array.from({ length }, (_, i) => code[i] ?? ""); export function OtpInput({ length = 6, value, defaultValue = "", onChange, onComplete, type = "numbers", size = "md", variant = "default", status = "idle", mask = false, disabled = false, autoFocus = false, separator, groupSize, className, slotClassName, ...props }: OtpInputProps) { const instanceId = useId(); const [uncontrolled, setUncontrolled] = useState(() => toSlots(defaultValue, length), ); const [clearedSlot, setClearedSlot] = useState(null); const [focusedIndex, setFocusedIndex] = useState(null); const [caretX, setCaretX] = useState(0); const [isCaretMoving, setIsCaretMoving] = useState(false); const [lastPunchedIndex, setLastPunchedIndex] = useState(null); const [prevStatus, setPrevStatus] = useState(status); const [isCelebrating, setIsCelebrating] = useState(false); if (prevStatus !== status) { setPrevStatus(status); if (status === "success") { setIsCelebrating(true); } else { setIsCelebrating(false); } } const inputs = useRef<(HTMLInputElement | null)[]>([]); const cells = useRef<(HTMLDivElement | null)[]>([]); const rowRef = useRef(null); const editingAt = useRef(null); const caretTimerRef = useRef | null>(null); const reduceMotion = useReducedMotion(); useEffect(() => { if (isCelebrating) { const timer = setTimeout( () => { setIsCelebrating(false); }, 500 + length * 50, ); return () => clearTimeout(timer); } }, [isCelebrating, length]); const slots = value === undefined ? Array.from({ length }, (_, i) => uncontrolled[i] ?? "") : toSlots(value, length); const numeric = type === "numbers"; const scale = SIZES[size]; const caretVisible = focusedIndex !== null && !slots[focusedIndex] && status !== "error"; const updateCaretPosition = (index: number) => { const cell = cells.current[index]; const row = rowRef.current; if (cell && row) { const cellRect = cell.getBoundingClientRect(); const rowRect = row.getBoundingClientRect(); const targetX = cell.offsetParent === row ? cell.offsetLeft + cell.offsetWidth / 2 : cellRect.left - rowRect.left + cellRect.width / 2; setIsCaretMoving(true); setCaretX(targetX); if (caretTimerRef.current) clearTimeout(caretTimerRef.current); caretTimerRef.current = setTimeout(() => { setIsCaretMoving(false); }, 240); } }; useEffect(() => { if (focusedIndex !== null) { updateCaretPosition(focusedIndex); const frame = requestAnimationFrame(() => { updateCaretPosition(focusedIndex); }); return () => cancelAnimationFrame(frame); } }, [focusedIndex, size, length]); const commit = (next: string[]) => { if (value === undefined) setUncontrolled(next); const code = next.join(""); onChange?.(code); if (next.every(Boolean)) onComplete?.(code); }; const focusAt = (index: number) => { const target = Math.min(Math.max(index, 0), length - 1); const input = inputs.current[target]; input?.focus(); input?.select(); setFocusedIndex(target); updateCaretPosition(target); }; const setCharAt = (index: number, char: string) => { if (!char) { setClearedSlot(index); } else { setClearedSlot(null); setLastPunchedIndex(index); setTimeout(() => setLastPunchedIndex(null), 180); } const next = slots.map((slot, i) => (i === index ? char : slot)); commit(next); }; const fill = (startIndex: number, chars: string[]) => { const availableSpace = Math.min(chars.length, length - startIndex); const next = [...slots]; chars.slice(0, availableSpace).forEach((char, i) => { next[startIndex + i] = char; }); setClearedSlot(null); commit(next); editingAt.current = null; focusAt(startIndex + availableSpace); }; const handleChange = (index: number, raw: string) => { const filtered = raw.split("").filter((char) => PATTERNS[type].test(char)); if (!filtered.length) { if (raw === "") { setCharAt(index, ""); } return; } const typedChar = filtered.length === 1 ? filtered[0] : filtered.length === 2 && filtered[0] === slots[index] ? filtered[1] : null; if (typedChar === null) { fill(index, filtered); return; } if (slots.every(Boolean) && editingAt.current !== index) return; setCharAt(index, typedChar); editingAt.current = null; focusAt(index + 1); }; const handleKeyDown = ( index: number, event: KeyboardEvent, ) => { if (event.key === "ArrowLeft") { event.preventDefault(); editingAt.current = Math.max(index - 1, 0); focusAt(index - 1); return; } if (event.key === "ArrowRight") { event.preventDefault(); editingAt.current = Math.min(index + 1, length - 1); focusAt(index + 1); return; } if (event.key === "Backspace") { event.preventDefault(); if (slots[index]) { setCharAt(index, ""); } else if (index > 0) { setCharAt(index - 1, ""); focusAt(index - 1); } return; } if (event.key === "Delete") { event.preventDefault(); setCharAt(index, ""); return; } }; const handlePaste = ( index: number, event: ClipboardEvent, ) => { event.preventDefault(); const pasted = event.clipboardData .getData("text") .split("") .filter((char) => PATTERNS[type].test(char)); if (pasted.length) fill(index, pasted); }; const handlePointerDown = ( index: number, event: PointerEvent, ) => { const firstEmpty = slots.findIndex((slot) => !slot); const target = firstEmpty === -1 ? index : Math.min(index, firstEmpty); editingAt.current = target; if (target === index) return; event.preventDefault(); focusAt(target); }; const getVariantStyles = (filled: boolean, isFocused: boolean) => { switch (variant) { case "glass": return cn( "bg-black/[0.03] dark:bg-white/3 backdrop-blur-md border border-black/10 dark:border-white/10 shadow-[inset_0_1px_1px_rgba(0,0,0,0.05)] dark:shadow-[inset_0_1px_1px_rgba(255,255,255,0.1)]", filled && "border-black/25 bg-black/[0.06] dark:border-white/25 dark:bg-white/8", isFocused && "border-black/40 shadow-[0_0_20px_rgba(0,0,0,0.1)] dark:border-white/40 dark:shadow-[0_0_20px_rgba(255,255,255,0.15)]", ); case "neon": return cn( "bg-zinc-100 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-800 shadow-[0_0_12px_rgba(0,0,0,0.05)] dark:shadow-[0_0_12px_rgba(0,0,0,0.5)]", filled && "border-zinc-500 shadow-[0_0_16px_rgba(0,0,0,0.08)] dark:border-zinc-600 dark:shadow-[0_0_16px_rgba(255,255,255,0.06)]", isFocused && "border-zinc-900 shadow-[0_0_24px_rgba(0,0,0,0.15)] dark:border-zinc-300 dark:shadow-[0_0_24px_rgba(255,255,255,0.2)]", ); case "underlined": return cn( "bg-transparent border-b-2 rounded-none! border-zinc-300 dark:border-zinc-700 shadow-none", filled && "border-zinc-600 dark:border-zinc-400", isFocused && "border-zinc-950 dark:border-white", ); default: return cn( "bg-zinc-100/90 dark:bg-[#161619] border border-black/10 dark:border-white/10 shadow-[0_2px_8px_rgba(0,0,0,0.04),inset_0_1px_0_rgba(255,255,255,0.8)] dark:shadow-[0_2px_8px_rgba(0,0,0,0.04),inset_0_1px_0_rgba(255,255,255,0.08)]", filled && "border-black/25 dark:border-white/20 bg-zinc-200/60 dark:bg-[#1a1a1e]", isFocused && "border-black/40 dark:border-white/35", ); } }; return (
{ if (!event.currentTarget.contains(event.relatedTarget as Node)) { setFocusedIndex(null); } }} animate={{ x: status === "error" && !reduceMotion ? SHAKE_KEYFRAMES : 0, }} className={cn("relative flex items-center", scale.gap)} > {slots.map((slot, index) => { const isFocused = focusedIndex === index; const isFilled = Boolean(slot); const isLastPunched = lastPunchedIndex === index; const showSeparator = separator && groupSize && index > 0 && index % groupSize === 0; return ( {showSeparator && ( )} { cells.current[index] = el; }} data-slot="otp-input-cell" data-filled={isFilled} data-focused={isFocused} layout transition={{ layout: reduceMotion ? { duration: 0 } : SIZE_SPRING, }} whileHover={ disabled || reduceMotion || status === "success" || status === "error" || isCelebrating ? undefined : { y: -1.5, scale: 1.02 } } animate={ isCelebrating && !reduceMotion ? { y: [0, -10, 0], scale: [1, 1.07, 1], transition: { duration: 0.5, delay: index * 0.05, ease: [0.34, 1.56, 0.64, 1], }, } : status === "loading" && !reduceMotion ? { opacity: [0.5, 1, 0.5], y: [0, -3, 0], transition: { repeat: Infinity, duration: 1.2, delay: index * 0.12, ease: "easeInOut", }, } : isLastPunched && !reduceMotion ? { scale: [1, 1.12, 1], y: [0, -2, 0], transition: { duration: 0.22, ease: "easeOut" }, } : isFocused && !reduceMotion && status !== "success" ? { scale: 1.04, y: -1.5, transition: { type: "spring", stiffness: 450, damping: 28, }, } : { scale: 1, y: 0, transition: { type: "spring", stiffness: 450, damping: 28, }, } } className={cn( "relative flex items-center justify-center transition-colors duration-200", scale.box, getVariantStyles(isFilled, isFocused), slotClassName, )} > {isFocused && !reduceMotion && status !== "success" && ( )} { inputs.current[index] = el; }} data-slot="otp-input-slot" data-filled={isFilled} value={slot} onChange={(event) => handleChange(index, event.target.value)} onKeyDown={(event) => handleKeyDown(index, event)} onPaste={(event) => handlePaste(index, event)} onPointerDown={(event) => handlePointerDown(index, event)} onFocus={() => { setFocusedIndex(index); updateCaretPosition(index); }} type={mask ? "password" : "text"} inputMode={numeric ? "numeric" : "text"} autoCapitalize={numeric ? undefined : "characters"} autoComplete={index === 0 ? "one-time-code" : "off"} autoFocus={autoFocus && index === 0} disabled={disabled} aria-label={ (numeric ? "Digit " : "Character ") + (index + 1) + " of " + length } className="absolute inset-0 size-full opacity-0 cursor-pointer disabled:cursor-not-allowed z-30" /> {status === "success" && ( )}
{slot && ( {mask ? ( ) : ( slot )} )}
); })} {caretVisible && (
); } export default OtpInput;