How to build a 2FA OTP input in React with auto-focus and paste support
A React 2FA OTP input renders N individual single-digit fields, advances focus automatically on each keystroke, jumps back on Backspace, and handles clipboard paste by distributing digits across fields. Framer Motion drives the card entrance, per-digit stagger, and the success screen swap via AnimatePresence.
- Stack: React 18 + Framer Motion 11 + Lucide React, ~310 lines, no extra library needed.
- Core React APIs: useState, useRef, useCallback, useEffect, useInView.
- Clipboard paste: strips non-digits, fills fields left-to-right, focuses the next empty slot.
- Resend countdown uses a simple 1-second setTimeout chain that clears on unmount.
- Accessible: inputMode='numeric' triggers the numeric keyboard on mobile; fields are individually focusable.
This auth section delivers a complete two-factor verification screen: six individual OTP digit fields, auto-focus progression, full keyboard navigation, clipboard paste, a resend countdown, and an animated success state. It covers the micro-interaction details that users notice, the border highlights as they type, the smooth error message entrance, the satisfying lock icon that scales in on success.
Anatomy
The layout is a single centered column with a maximum width of 400px. At the top sits a ShieldCheck icon in an accent-colored rounded box, followed by the title and subtitle. Below that, the six OTP inputs form a flex row with 8px gaps; each input is 48px wide and 56px tall. A verify button spans the full width, transitioning between a muted disabled state and the accent fill when all fields are complete. The resend row and back link sit below. On success, AnimatePresence swaps the form for a success card featuring a bouncing Lock icon.
How it works
Each input field holds exactly one character; the onChange handler strips non-digits, writes the value into a code string array, then calls focus on inputRefs[index + 1] when the field is filled. The Backspace handler checks whether the current field is empty, if so, it moves focus to inputRefs[index - 1], giving seamless backward editing. Paste is handled only on the first field: clipboard text is sanitized to digits only, spread across the array, then focus lands on the next empty slot. The countdown runs through a useEffect that schedules a 1-second timeout and decrements the state; the effect cleans up between ticks to avoid accumulation. Framer Motion provides two layers of animation: a single card entrance (opacity 0→1, y 30→0 with a spring ease) and a per-digit stagger (delay = index × 0.05s) so the fields appear to cascade in. AnimatePresence with mode='wait' handles the form-to-success transition without layout shift.
How to build it in React
Create the digit array state and refs
Initialize a string array with Array(codeLength).fill('') and a parallel inputRefs array of the same length. The parallel ref array lets you imperatively call focus() on any field by index, something React state alone cannot do.
const [code, setCode] = useState<string[]>(Array(codeLength).fill("")); const inputRefs = useRef<(HTMLInputElement | null)[]>([]);Wire up onChange, Backspace, and paste
In onChange, reject non-digits with a regex guard, write the last character to the right slot, then advance focus. In onKeyDown, when Backspace fires on an empty field move focus one step back. Attach the paste handler to the first input only, parse digits, fill the array, then focus the next empty index.
const handleChange = (index: number, value: string) => { if (!/^\d*$/.test(value)) return; const next = [...code]; next[index] = value.slice(-1); setCode(next); if (value && index < codeLength - 1) inputRefs.current[index + 1]?.focus(); };Animate fields with per-digit stagger
Replace the plain <input> with <motion.input> and add an initial/animate pair that reads from the useInView hook. Set delay to i * 0.05 so each field cascades in 50ms after the previous one. This keeps the entrance lively without overwhelming the user.
<motion.input initial={{ opacity: 0, y: 10 }} animate={inView ? { opacity: 1, y: 0 } : {}} transition={{ duration: 0.3, delay: i * 0.05, ease: EASE }} />Swap to success state with AnimatePresence
Wrap both the form and the success card in <AnimatePresence mode='wait'>. Give each a unique key so Framer Motion exits the old view before mounting the new one. The success card uses a nested scale animation on the Lock icon (scale 0→1) to reinforce the verification metaphor.
When to use it
Use this component as the dedicated 2FA step in a multi-screen auth flow, directly after password login when the user has SMS or TOTP configured. It works for SaaS dashboards, admin panels, banking, and any product where a second factor is required. Skip it when 2FA is optional or not yet implemented; showing this screen to users who have not enrolled will create confusion. On mobile the numeric keyboard triggers automatically via inputMode='numeric', but test paste behavior in Safari as clipboard access has known quirks.
Used by
- GitHub, Uses an OTP digit-box layout for SMS and authenticator app 2FA, with auto-advance focus on each digit entry.
- Stripe, Verification codes during login use individual digit fields with the same paste-and-distribute pattern.
- Linear, Minimal centered 2FA screen with digit inputs and a resend link, consistent with their sparse, keyboard-first UX.
- Vercel, OTP entry during team login uses individual boxes with automatic focus advancement and keyboard backspace support.
FAQ
How does the paste handler work across all six fields?
The paste listener is attached only to the first input. When fired, it calls e.preventDefault() to stop the browser inserting raw text, extracts only digit characters from the clipboard string, slices to codeLength, then maps each digit into the code array. Focus then moves to the first empty slot, or to the last field if all slots are filled.
Why use individual inputs instead of a single text field?
Separate inputs give you visual progress (each box fills as the user types), natural keyboard navigation via Backspace, and control over what the virtual keyboard displays on mobile. A single hidden input is an alternative but requires a custom rendering layer, adding complexity with no real UX benefit for a fixed-length code.
Can I change the code length from 6 to 4 or 8 digits?
Pass a different codeLength prop. The array state, input rendering loop, and validation all derive from that value, so no further changes are needed. The flex row may need a narrower gap or smaller field dimensions on very small screens when using 8 digits.
How do I connect this to a real TOTP or SMS verification backend?
Replace the handleSubmit simulation with an async function that sends the code to your API (e.g. POST /auth/verify-otp). On success update verified state; on a 4xx response set the error state and optionally clear the fields. The resend handler calls your send-code endpoint and resets the countdown.