Build an OTP Input Component
Learn how to build a one-time password input with individual digit boxes, auto-advance focus, backspace navigation, paste support, and numeric validation. A popular interview question that tests refs, focus management, and keyboard event handling.
Table of Contents
Problem Statement
Build an OTP (one-time password) input component with individual digit boxes. This is the kind of input you see on login screens, 2FA flows, and verification pages.
- Render N individual input boxes (one digit each)
- Auto-focus the first input on mount
- On typing a digit, auto-advance focus to the next input
- On Backspace in an empty input, move focus to the previous input
- Support pasting a full OTP (e.g., '123456') — distribute across inputs
- Only allow numeric input (0-9)
- Detect when all digits are filled and show the complete OTP
- Provide a Clear button to reset all inputs
Why this question?
OTP inputs test a unique combination of skills: managing an array of refs for focus control, handling keyboard events (onChange vs onKeyDown and when each fires), clipboard handling with onPaste, and input validation. It's compact but has many subtle edge cases that separate strong candidates.
Component Anatomy
The UI is simple — a row of input boxes with a status indicator below.
Digit Inputs
N individual <input> elements, each accepting one digit. Styled as square boxes in a row. Each has its own ref for focus management.
Ref Array
An array of refs (one per input) stored in a single useRef. Used to programmatically move focus between inputs on typing and backspace.
OTP State
An array of strings (one per digit). Updated on typing, paste, and clear. When all entries are non-empty, the OTP is complete.
Status Display
Shows 'Enter your OTP' while incomplete, and 'OTP: 123456 ✓' when all digits are filled. Driven by checking if every digit is non-empty.
┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │ 1 │ │ 3 │ │ │ │ │ │ │ │ │ ← Individual inputs └────┘ └────┘ └────┘ └────┘ └────┘ └────┘ ↑ ↑ ↑ ref[0] ref[1] ref[2] ... ← Ref array for focus [ Clear ] ← Reset button Enter your 6-digit OTP ← Status text
State & Refs Design
The OTP is stored as an array of strings. Each input has a corresponding ref for focus control.
const OTP_LENGTH = 6; const [otp, setOtp] = useState<string[]>( Array(OTP_LENGTH).fill("") ); // → ["", "", "", "", "", ""] const inputRefs = useRef<(HTMLInputElement | null)[]>([]); // Auto-focus first input on mount useEffect(() => { inputRefs.current[0]?.focus(); }, []); // Assign refs in JSX: <input ref={(el) => { inputRefs.current[i] = el; }} />
| Piece | Type | Purpose |
|---|---|---|
| otp | string[] | Array of digits, one per input box |
| inputRefs | (HTMLInputElement | null)[] | Array of refs for programmatic focus |
| isComplete | boolean (derived) | True when every digit is non-empty |
Why an array of refs instead of multiple useRefs?
Creating 6 separate useRef calls is verbose and doesn't scale. A single useRef holding an array lets you access any input by index: inputRefs.current[i]. The callback ref pattern ref={(el) => { inputRefs.current[i] = el }} populates the array during render.
Auto-Advance on Typing
When the user types a digit, update the state and move focus to the next input. Reject non-numeric input.
const handleChange = (index: number, value: string) => { // Reject non-numeric input if (!/^\d*$/.test(value)) return; // Take only the last character (handles overwrite) const digit = value.slice(-1); const newOtp = [...otp]; newOtp[index] = digit; setOtp(newOtp); // Auto-advance to next input if (digit && index < OTP_LENGTH - 1) { inputRefs.current[index + 1]?.focus(); } };
Validate input
Test the value against /^\d*$/. If it contains non-digits, return early. This blocks letters, symbols, and spaces.
Extract the digit
Use value.slice(-1) to get the last character. This handles the case where the input already has a value and the user types another digit — we take the new one.
Update state and advance
Set the digit in the otp array. If a digit was entered and we're not on the last input, focus the next one.
Backspace Navigation
Backspace has two behaviors: if the current input has a value, the browser deletes it (handled by onChange). If it's empty, we move focus to the previous input. This requires onKeyDown.
const handleKeyDown = (index: number, e: React.KeyboardEvent) => { if (e.key === "Backspace" && !otp[index] && index > 0) { e.preventDefault(); // Stop Backspace from deleting in the previous input inputRefs.current[index - 1]?.focus(); } };
Why e.preventDefault() matters here
Without e.preventDefault(), the Backspace keypress still propagates after focus moves. The browser executes its default action (delete a character) on the newly focused input — wiping out its value. With e.preventDefault(), focus moves but the previous digit stays intact. This is a subtle bug that most candidates miss.
onChange vs onKeyDown — when does each fire?
| Scenario | onChange | onKeyDown |
|---|---|---|
| Type "5" in empty box | ✓ fires (value: "" → "5") | ✓ fires |
| Backspace on box with "5" | ✓ fires (value: "5" → "") | ✓ fires |
| Backspace on empty box | ✗ does NOT fire (value unchanged) | ✓ fires |
This is why you need both handlers: onChange for value updates, onKeyDown for catching Backspace on empty inputs.
Paste Support
Users often copy-paste OTPs from SMS or email. The paste handler reads the clipboard, strips non-digits, and distributes characters across the inputs.
const handlePaste = (e: React.ClipboardEvent) => { e.preventDefault(); // Read clipboard and keep only digits const pasted = e.clipboardData .getData("text") .replace(/\D/g, "") // Strip non-digits .slice(0, OTP_LENGTH); // Take at most N chars if (!pasted) return; // Distribute digits across the otp array const newOtp = [...otp]; pasted.split("").forEach((char, i) => { newOtp[i] = char; }); setOtp(newOtp); // Focus the next empty input, or the last one const focusIndex = Math.min(pasted.length, OTP_LENGTH - 1); inputRefs.current[focusIndex]?.focus(); };
Prevent default paste
e.preventDefault() stops the browser from pasting the full string into the single focused input. We handle distribution manually.
Clean the pasted text
Strip non-digits with .replace(/\D/g, "") and limit to OTP_LENGTH characters. "abc123456xyz" becomes "123456".
Distribute and focus
Split into characters and fill the otp array from index 0. Focus the input after the last pasted digit.
Why attach onPaste to every input?
The user might paste while focused on any input, not just the first one. Attaching onPaste to every input ensures it works regardless of which box is focused. The handler always fills from index 0 for simplicity.
Full Implementation
Here's the complete OTP input component. Study how the state, refs, and three event handlers (onChange, onKeyDown, onPaste) work together.
"use client"; import { useState, useRef, useEffect } from "react"; const OTP_LENGTH = 6; export default function OtpComponentPage() { const [otp, setOtp] = useState<string[]>( Array(OTP_LENGTH).fill("") ); const [isComplete, setIsComplete] = useState(false); const inputRefs = useRef<(HTMLInputElement | null)[]>([]); useEffect(() => { inputRefs.current[0]?.focus(); }, []); const handleChange = (index: number, value: string) => { if (!/^\d*$/.test(value)) return; const digit = value.slice(-1); const newOtp = [...otp]; newOtp[index] = digit; setOtp(newOtp); if (digit && index < OTP_LENGTH - 1) { inputRefs.current[index + 1]?.focus(); } setIsComplete(newOtp.every((d) => d !== "")); }; const handleKeyDown = (index: number, e: React.KeyboardEvent) => { if (e.key === "Backspace" && !otp[index] && index > 0) { e.preventDefault(); inputRefs.current[index - 1]?.focus(); } }; const handlePaste = (e: React.ClipboardEvent) => { e.preventDefault(); const pasted = e.clipboardData .getData("text") .replace(/\D/g, "") .slice(0, OTP_LENGTH); if (!pasted) return; const newOtp = [...otp]; pasted.split("").forEach((char, i) => { newOtp[i] = char; }); setOtp(newOtp); const focusIndex = Math.min(pasted.length, OTP_LENGTH - 1); inputRefs.current[focusIndex]?.focus(); setIsComplete(newOtp.every((d) => d !== "")); }; const clearOtp = () => { setOtp(Array(OTP_LENGTH).fill("")); setIsComplete(false); inputRefs.current[0]?.focus(); }; return ( <div> <div className="flex gap-3"> {otp.map((digit, i) => ( <input key={i} ref={(el) => { inputRefs.current[i] = el; }} type="text" inputMode="numeric" maxLength={1} value={digit} onChange={(e) => handleChange(i, e.target.value)} onKeyDown={(e) => handleKeyDown(i, e)} onPaste={handlePaste} /> ))} </div> <button onClick={clearOtp}>Clear</button> {isComplete && <p>OTP: {otp.join("")} ✓</p>} </div> ); }
Numeric-only validation
Regex test /^\d*$/ rejects non-numeric input before updating state. Combined with inputMode='numeric' for mobile keyboards.
preventDefault on Backspace
Prevents the browser from deleting the previous input's value when focus moves on Backspace. A subtle but critical edge case.
Paste support
Reads clipboard, strips non-digits, distributes across inputs. Works regardless of which input is focused.
Auto-submit on complete
Automatically call a verify API when all digits are filled. Add a loading state and error handling for the verification response.
Resend OTP timer
Add a countdown timer (e.g., 30s) before allowing the user to request a new OTP. Use setInterval with cleanup.
Common Interview Follow-up Questions
After building the OTP input, interviewers explore edge cases and deeper understanding:
Q:Why does onChange not fire when pressing Backspace on an empty input?
A: onChange only fires when the input's value actually changes. Backspace on an empty input means the value goes from '' to '' — no change, no event. That's why onKeyDown is needed: it fires on every keypress regardless of whether the value changes.
Q:Why use e.preventDefault() in the Backspace handler instead of e.stopPropagation()?
A: stopPropagation stops the event from bubbling to parent elements, but the browser's default action (deleting a character) still executes. preventDefault stops the default action itself. When we move focus to the previous input, we need to prevent Backspace from deleting that input's value — only preventDefault does that.
Q:How would you handle variable-length OTPs?
A: Make OTP_LENGTH a prop. The component already uses it to generate the array and refs. The parent component passes the length based on the backend's requirements. No other changes needed.
Q:How would you add auto-submit when the OTP is complete?
A: In handleChange and handlePaste, after setting state, check if all digits are filled. If so, call a verify function with otp.join(''). Add loading and error states. Disable inputs during verification and show a spinner.
Q:How would you handle the case where the user pastes in the middle?
A: The current implementation always fills from index 0. To paste from the current position, start filling from the focused input's index instead. Adjust: pasted.split('').forEach((char, i) => newOtp[index + i] = char) with bounds checking.
Q:What about accessibility?
A: Add aria-label='Digit N of 6' to each input. Use role='group' with aria-label='OTP input' on the container. Announce completion with aria-live='polite'. Ensure the inputs are reachable via Tab. The inputMode='numeric' attribute shows the numeric keyboard on mobile.
Q:How would you add a masked/hidden mode (like password dots)?
A: Change the input type to 'password' or use CSS to replace the digit with a dot after a short delay. Show the digit briefly (300ms) then replace with '●'. Use a timeout per input that swaps the display value.
Q:Could you implement this with a single hidden input instead?
A: Yes — use one hidden input that captures all keystrokes, and render the digits as styled divs. This avoids focus management entirely. The hidden input handles typing, backspace, and paste natively. Libraries like react-otp-input use this approach. Trade-off: simpler logic but harder to style individual boxes.
Ready to build it yourself?
We've set up the input boxes and UI shell. Implement the focus management, keyboard handling, and paste support from scratch.
Built for developers, by developers. Happy coding! 🚀