How to build a 6-digit OTP input in React
A React OTP input splits a single code into individual controlled inputs, moves focus forward on digit entry and backward on Backspace, and validates each keystroke against a numeric pattern. Pair it with a Framer Motion fade-up entry on the wrapping card to finish the animation in under 150 lines.
- Stack: React 18 + Framer Motion + Lucide React + Tailwind v4, ~87 lines total.
- Focus management uses a useRef array, no third-party OTP library required.
- Each filled input gets a 2px accent border; empty inputs keep a 1px neutral border. The visual state is driven purely by CSS custom properties.
- inputMode='numeric' triggers the numeric keyboard on iOS and Android without blocking paste.
- The codeLength prop is configurable (default 6), so 4-digit and 8-digit codes reuse the same component without modification.
AuthVerifyEmail is a centered email verification screen built around a configurable multi-box OTP input. The card animates in with a spring fade-up, then the digit boxes handle all keyboard navigation internally so developers drop it in without wiring focus logic from scratch. It covers the most common auth step that teams tend to underdesign.
Anatomy
The screen is a single motion.div centered in a min-h-screen container. Inside, top to bottom: a circular icon badge (Mail from Lucide, accent-colored), the title h1, an optional subtitle and email address line, the OTP digit row, a full-width submit button, and a resend link. The digit row maps over a string array of length codeLength and renders one controlled text input per slot.
How it works
State is a string array, one slot per digit, all empty by default. handleChange filters non-numeric input with a regex test, writes the last typed character to the matching index, then moves focus to the next input via the ref array. handleKeyDown catches Backspace on an empty slot and moves focus one step back. The Framer Motion entry plays once on mount: opacity 0 to 1, y 20 to 0, over 600ms with a custom spring ease curve.
How to build it in React
Initialize digit state and refs
Create a string array of empty slots equal to codeLength, then a parallel useRef array for the inputs. The refs let you call .focus() imperatively without managing a focus index in state.
const [code, setCode] = useState<string[]>( Array.from({ length: codeLength }, () => "") ); const inputRefs = useRef<(HTMLInputElement | null)[]>([]);Handle digit entry with auto-advance
On each change event, reject anything that is not a digit, take only the last character (handles paste of a single digit), update the array, then focus the next slot if one exists.
function handleChange(index: number, value: string) { if (!/^d*$/.test(value)) return; const newCode = [...code]; newCode[index] = value.slice(-1); setCode(newCode); if (value && index < codeLength - 1) { inputRefs.current[index + 1]?.focus(); } }Navigate backward on Backspace
When the current slot is already empty and the user presses Backspace, move focus to the previous slot. This makes correction feel natural without extra state tracking.
function handleKeyDown(index: number, e: React.KeyboardEvent) { if (e.key === "Backspace" && !code[index] && index > 0) { inputRefs.current[index - 1]?.focus(); } }Animate the card in with Framer Motion
Wrap the container in a motion.div with initial opacity 0 and y 20, animate to opacity 1 and y 0, and pass a spring ease array. The animation plays once on mount and does not depend on interaction state.
const ease: [number, number, number, number] = [0.16, 1, 0.3, 1]; <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.6, ease }} >
When to use it
Use this component wherever your auth flow includes email or phone verification: sign-up confirmation, two-factor authentication, or password-reset codes. It fits SaaS onboarding, fintech apps, and any product that needs to confirm device or address ownership. Skip it when your flow uses a magic link instead of a code; the multi-input pattern would be misleading with no digits to enter.
Used by
- Stripe, Uses a 6-digit OTP box row for two-factor authentication on the Dashboard login.
- GitHub, Presents a digit-box OTP input during 2FA setup and device verification.
- Linear, Email verification step uses a segmented numeric code field consistent with its minimal design system.
- Notion, Login confirmation via email sends a 6-digit code entered in a split-input UI.
FAQ
How do I handle paste of a full code?
Add an onPaste handler on the first input that reads e.clipboardData.getData('text'), splits it into individual characters, filters non-digits, fills the code array, and focuses the last filled slot. The current component handles single-digit paste per slot; full-code paste needs one extra handler.
Can I change the number of digits without forking the component?
Pass a different codeLength prop, the component derives both the state array and the rendered inputs from that value. 4-digit SMS codes and 8-digit backup codes work with no other changes.
Is inputMode='numeric' enough for mobile keyboards?
On most devices yes, it surfaces the numeric pad on iOS and Android without restricting paste or clipboard APIs. Adding pattern='[0-9]*' as well covers older Android WebViews that ignore inputMode.
How do I submit automatically when the last digit is entered?
Inside handleChange, after setting the new code array, check if the updated index equals codeLength - 1 and the new value is non-empty; if so, call your submit function with the joined code string. A useEffect watching the code array for a complete state is equally valid.