React OTP Input: 6-Field Build Guide
This guide walks you through building a six-field OTP (one-time password) input in React, the kind you see in banking apps and two-factor authentication flows. You’ll end up with a reusable component where each digit has its own box, focus moves automatically to the next empty field as you type, and pasting a full code fills all six fields at once. You’ll need a working React project (Create React App, Vite, or Next.js all work) and a basic understanding of React hooks and controlled inputs.
What You’ll Need
- A React project set up with npm or yarn
- Basic familiarity with React hooks (useState, useRef, useEffect)
- Understanding of controlled form inputs
- No external libraries required — this is pure React
Step-by-Step
- Create a new component file named
OtpInput.jsx(or.tsxif using TypeScript). This keeps the logic isolated and reusable. - Set up the component skeleton with props. The component should accept
length(default 6),onComplete(callback when all digits are filled), and optionallyvalueandonChangefor controlled usage. Start with a function that returns a container div. - Create state for the OTP values. Use
useStateto hold an array of strings, one for each input. Initialize as an array of empty strings:const [otp, setOtp] = useState(Array(length).fill('')). - Create refs for each input. Use
useRefto store an array of input elements. This allows you to focus specific inputs programmatically. Initialize withuseRef([]). - Render the six input fields. Map over the
otparray and render an<input>for each. SetmaxLength={1},inputMode="numeric"(to bring up the number pad on mobile), andautoComplete="one-time-code"(helps mobile browsers suggest the code from SMS). Style each input to be square, centered, and with a gap between them. - Handle input changes. Write a function
handleChange(e, index)that extracts the typed digit, updates theotparray at that index, and moves focus to the next input if the digit is not empty. Userefs.current[index + 1]?.focus(). - Handle backspace navigation. Add an
onKeyDownhandler. If the key isBackspaceand the current field is empty, move focus to the previous input. If it has a value, clear it and stay (or move back after clearing). This makes deleting a sequence feel natural. - Handle paste events. Add an
onPastehandler on the container. Extract the pasted text, strip non-digits, and fill the firstlengthcharacters into theotparray. Then focus the last filled input (or the next empty one). - Add a completion effect. Use
useEffectto watch theotparray. If every element is non-empty, join them into a string and callonCompletewith that value. Also callonChangeif provided. - Handle arrow key navigation (optional but nice). In the
onKeyDownhandler, also check forArrowLeftandArrowRightto move focus accordingly. This improves accessibility. - Style the inputs. Use CSS to make the inputs look like classic OTP boxes: fixed width/height (e.g., 48px), centered text, a bottom border or box shadow, and a focus state that changes the border color. You can add a slight transition for smoothness.
- Test the component. In your app, render
<OtpInput onComplete={(code) => console.log(code)} />. Try typing, deleting, and pasting a 6-digit code to verify everything works.
Common Mistakes
- Not clearing the input on backspace when it has a value. Users expect that pressing backspace on a filled field clears it and moves focus back. If you only move focus when empty, the user has to press backspace twice.
- Using
type="number". This can cause unwanted scrollbars on some browsers and allows characters like ‘e’, ‘+’, ‘-‘. Usetype="text"withinputMode="numeric"and filter non-digits in the handler. - Ignoring the paste event. Many users will paste the code from an SMS or authenticator app. Without a paste handler, the text goes into one field and breaks the input.
- Not handling the case where the user types a digit in a field that already has a value. If the user clicks a filled field and types a new digit, it should replace the old one, not append. Ensure your
handleChangesets the value, not concatenates. - Forgetting to set
autoComplete="one-time-code". This is a small but important detail for mobile UX. Without it, the browser may not offer to autofill the code from a text message.
Tips
- Use a single state array rather than six separate states. It’s cleaner and easier to manage for paste and completion logic.
- Make the component controlled or uncontrolled depending on your needs. If you need to clear the OTP from a parent (e.g., after a failed verification), pass a
valueprop and reset it. Otherwise, keep it internal. - Add a visual focus indicator (like a thicker border) so users know which box is active. This improves usability significantly.
- Consider mobile keyboard behavior. Using
inputMode="numeric"brings up the number pad. Also, setpattern="[0-9]*"to help iOS show the numeric keyboard. - Test with screen readers. Add
aria-label="Digit 1"or similar to each input for accessibility. Or wrap the container in a fieldset with a legend.
Troubleshooting
- Focus jumps to the wrong field. Check that your refs array is correctly indexed. The
refcallback should beref={(el) => (refs.current[index] = el)}. If you use a single ref for all, it won’t work. - Paste doesn’t fill all fields. Ensure your paste handler reads
e.clipboardData.getData('text')and that you calle.preventDefault()to stop the default paste action. Also strip non-digits with.replace(/\D/g, ''). - Backspace doesn’t move focus. Remember that the
onKeyDownfires before the value is cleared. So if you checke.target.value, it still has the old value. Check if the value is empty, and if not, clear it and then move focus. - Completion fires prematurely. Make sure you’re checking that all values are non-empty, not just the last one. Use
otp.every(v => v !== ''). - Mobile keyboard doesn’t appear. Double-check that
inputMode="numeric"is set. Also, if you’re usingtype="tel"as an alternative, that works too, butinputModeis more standard.
Frequently Asked Questions
Can I use this with TypeScript?
Yes, the same logic works with TypeScript. You’ll need to type the refs array as useRef<(HTMLInputElement | null)[]>([]) and the OTP state as useState<string[]>(Array(length).fill('')).
How do I clear the OTP input after submission?
If you make the component controlled by passing a value prop and an onChange handler, the parent can reset the value to an empty array. Alternatively, expose a reset method via a ref using useImperativeHandle.
What if I need more than 6 digits?
The component accepts a length prop, so you can render 4, 8, or any number of fields. Just pass length={8} when using it. The logic scales automatically.
How do I handle the Enter key to submit the form?
Add an onKeyDown handler on the last input that checks for Enter and calls onComplete or submits the form. Alternatively, wrap the OTP in a form and use a submit button.
Can I style the inputs to look like boxes with a bottom border only?
Absolutely. Just remove the full border and use border-bottom: 2px solid #ccc. On focus, change the border-bottom color. This is a common design pattern for OTP inputs.
Why does the input allow letters when I type on desktop?
Because inputMode only affects mobile keyboards. On desktop, users can type any character. Your handleChange should filter out non-digits using value.replace(/\D/g, '') before setting state.
Related
Method: the standard formula for this calculation, rounded as each result states. See how the calculators work or report an error.