Build an OTP input
Build a 4-digit one-time-passcode input: four separate single-character boxes that read as one value.
Typing a digit in a box should move focus to the next box automatically. Pressing Backspace in a box that is already empty should move focus back to the previous box instead of doing nothing.
Examples
// 4 empty boxes. otp-value = ""
// Focus otp-0, type "1"
// → otp-value = "1", focus jumps to otp-1
// Type "2"
// → otp-value = "12", focus → otp-2
// Press Backspace in an empty otp-2
// → focus jumps back to otp-1
// Type into otp-3 → otp-value = "1234", focus stays on otp-3.Typing advances; Backspace in an empty box reverses — the two together make four inputs feel like one.
function handleKeyDown(index, e) {
if (e.key === "Backspace" && values[index] === "" && index > 0) {
inputRefs[index - 1].current?.focus();
}
}Guard with `values[index] === ""` — Backspace in a filled box should delete that char first, then move back on the next press.
What it has to do
- Four inputs, `data-testid="otp-0"` through `data-testid="otp-3"`, each holding at most one character.
- A `data-testid="otp-value"` element shows the concatenation of all four boxes as plain text.
- Typing a character into a box moves focus to the next box (if there is one).
- Pressing Backspace in an empty box moves focus to the previous box (if there is one) — Backspace in a non-empty box behaves normally otherwise.
- Typing in the last box does not error or try to move focus past it.
Notes
- Set `inputMode="numeric"` and `autoComplete="one-time-code"` on each box — iOS/Android surface the SMS code directly as a suggestion.
- Handle paste on `otp-0`: a user-pasted 4-digit code should fan out across the boxes, not land as "1" in the first box.
- `maxLength={1}` isn't a hard guarantee on some mobile keyboards — slice to one char in `onChange` to be safe.
- Keep the array of refs stable across renders — hoist once with `useRef` + fixed length; building a new array in render invalidates focus mid-type.
Stuck? The react course covers the ideas this problem is built on.
import { useRef, useState } from "react"; export default function App() { const [values, setValues] = useState(["", "", "", ""]); const inputRefs = [useRef(null), useRef(null), useRef(null), useRef(null)]; function handleChange(index, e) { const char = e.target.value.slice(-1); setValues((current) => { const next = [...current]; next[index] = char; return next; }); if (char && index < 3) { inputRefs[index + 1].current?.focus(); } } function handleKeyDown(index, e) { // TODO: pressing Backspace while this box is already empty should // move focus to the previous box (index - 1), if there is one. } return ( <div style={{ fontFamily: "sans-serif", padding: 16 }}> <div style={{ display: "flex", gap: 8 }}> {values.map((value, index) => ( <input key={index} data-testid={`otp-${index}`} ref={inputRefs[index]} value={value} maxLength={1} onChange={(e) => handleChange(index, e)} onKeyDown={(e) => handleKeyDown(index, e)} style={{ width: 32, height: 40, textAlign: "center", fontSize: 20 }} /> ))} </div> <p> Value: <span data-testid="otp-value">{values.join("")}</span> </p> </div> ); }
- 1.Four inputs, `data-testid="otp-0"` through `data-testid="otp-3"`, each holding at most one character.
- 2.A `data-testid="otp-value"` element shows the concatenation of all four boxes as plain text.
- 3.Typing a character into a box moves focus to the next box (if there is one).
- 4.Pressing Backspace in an empty box moves focus to the previous box (if there is one) — Backspace in a non-empty box behaves normally otherwise.
- 5.Typing in the last box does not error or try to move focus past it.
5 automated assertions run against your rendered component. The exact assertion names are hidden so you build to the behaviour, not to the test.
Discussion
Sign in to postNo comments yet. Be the first to say something.