"use client"; import { useId, useState, type CSSProperties, type SyntheticEvent } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; type OTPOrbitProps = { /** Number of characters in the code. */ length?: number; /** Called on every change with the code so far. */ onChange?: (code: string) => void; /** Called once when the last box is filled. */ onComplete?: (code: string) => void; /** Field name shown above the boxes. */ label?: string; /** Small help text under the boxes. */ hint?: string; /** Digits only (number keypad on phones). false = letters and digits. */ numeric?: boolean; /** Form field name, so the code is sent with a normal form. */ name?: string; /** Colour of the orbiting light and filled boxes. */ accent?: string; /** Wrapper look (width etc.). */ className?: string; }; /* OTPOrbit: a one-time-code field. A light orbits the box you are typing in, and each character pops in. One real input under the boxes, so paste and SMS autofill just work. */ export function OTPOrbit({ length = 6, onChange, onComplete, label = "Verification code", hint, numeric = true, name, accent = "#a78bfa", className = "w-full max-w-sm", }: OTPOrbitProps) { const reduce = useReducedMotion(); const id = useId(); const [code, setCode] = useState(""); const [focused, setFocused] = useState(false); const active = Math.min(code.length, length - 1); const change = (raw: string) => { const next = (numeric ? raw.replace(/\D/g, "") : raw.replace(/[^a-z0-9]/gi, "").toUpperCase()).slice(0, length); if (next === code) return; setCode(next); onChange?.(next); if (next.length === length) onComplete?.(next); }; // Keep the caret at the end so typing and Backspace always work on the last box. const pinCaret = (e: SyntheticEvent) => { const el = e.currentTarget; const end = el.value.length; if (el.selectionStart !== end || el.selectionEnd !== end) el.setSelectionRange(end, end); }; return (
{Array.from({ length }, (_, i) => { const ch = code[i]; const on = focused && i === active; return (
{on && !reduce && ( <> )} {ch && ( {ch} )} {on && !ch && }
); })} change(e.target.value)} onFocus={() => setFocused(true)} onBlur={() => setFocused(false)} onSelect={pinCaret} maxLength={length} inputMode={numeric ? "numeric" : "text"} pattern={numeric ? `\\d{${length}}` : undefined} autoComplete="one-time-code" autoCapitalize="characters" spellCheck={false} aria-describedby={hint ? `${id}-hint` : undefined} className="absolute inset-0 h-full w-full cursor-text bg-transparent text-base text-transparent caret-transparent opacity-0 outline-none selection:bg-transparent" />
{hint && (

{hint}

)}
); }