Devanshu BiswasYou meet it at every 2FA prompt, phone verification and "check your email for a code" screen: a strip...
You meet it at every 2FA prompt, phone verification and "check your email for a code" screen: a strip of little single-character boxes that feel like one field. The layout is trivial — a flex row of tiny inputs. The entire craft is in the behaviour, and there's a lot of it. I built a real OTP / verification-code input from scratch — no library — and the interesting part is everything the boxes do that a plain text field doesn't.
The first rule: don't hand-write six <input>s. Render them from a single LEN constant so the field is instantly 4, 6 or 8 boxes. Each is a real text input with maxlength="1" plus the attributes that make phones behave — inputmode="numeric" pops the number pad, autocomplete="one-time-code" lets iOS/Android offer the just-received SMS code, and a data-i index tells every handler which box fired.
const LEN = 6;
otp.innerHTML = Array.from({length: LEN}, (_, i) => `
<input class="otp-box" type="text" inputmode="numeric"
autocomplete="one-time-code" maxlength="1"
data-i="${i}" aria-label="Digit ${i+1} of ${LEN}">`
).join("");
const boxes = [...otp.querySelectorAll(".otp-box")];
const code = () => boxes.map(b => b.value).join("");
A code is digits, so I scrub everything else on the input event with value.replace(/\D/g, "") — a stray "a" never sticks. I do this in JS rather than trusting type="number", which quietly allows e, +, - and a spinner nobody wants. Then the signature feel: type a digit and focus hops to the next box, so the user keeps typing straight through the code without ever reaching for Tab. It's one line — if a digit landed and we're not on the last box, boxes[i + 1].focus(). On the last box I deliberately don't advance; that's where completion is detected.
Backspace has two jobs depending on the box's state, handled in keydown so I act before the value changes. If the box has a digit, let the default delete it in place. If it's already empty, the user is trying to fix the previous box — so preventDefault(), move focus back one, and clear that box too. One Backspace both jumps and erases, exactly like editing a single continuous field. Skip this and backspacing from an empty box does nothing, and the field feels broken.
function onKeydown(e){
const i = +e.target.dataset.i;
if (e.key === "Backspace" && e.target.value === "" && i > 0){
e.preventDefault();
boxes[i - 1].focus(); // jump back
boxes[i - 1].value = ""; // and clear it
afterChange();
}
// box has a value -> default Backspace clears it in place
}
Arrow keys walk between boxes in the same handler (guarded at both ends, each with preventDefault()), and a focus listener that calls b.select() means landing on a filled box and typing overwrites the digit instead of bouncing off maxlength.
Real users copy the code from their SMS and paste it. The naive field dumps all six digits into one box; the correct one spreads them. I listen for paste, preventDefault() so the browser's single-box insert never runs, keep only the digits, and write one per box. The key move is that the same spread() helper is reused by the digits-only guard, so a fast paste that slips extra characters into one box and a real paste event behave identically:
function spread(str, start = 0){
const ds = str.replace(/\D/g, "").split("");
for (let k = 0; k < ds.length && start + k < LEN; k++)
boxes[start + k].value = ds[k];
boxes[Math.min(start + ds.length, LEN - 1)].focus();
afterChange();
}
otp.addEventListener("paste", e => {
e.preventDefault();
spread(e.clipboardData.getData("text"), 0); // fill from the first box
});
Every mutation — typing, backspace, paste — routes through one afterChange(), so there's a single place that asks "are we done?". I join the boxes, test for exactly LEN digits, and only then call onComplete(code). Funnelling everything through one hook means completion can never be missed (paste fills all six at once and still triggers it) and never double-fires per keystroke. That's the contract a form actually cares about: give me the finished code, once. In the demo onComplete compares against the code that was "sent" — a match locks the field green with a verified tick, a mismatch paints it red, shakes the row, and clears for a retry.
The finish that separates a demo from something shippable is the honesty layer: a resend countdown modelled as a setInterval (always clearInterval before starting a new one, or a double-click stacks two timers counting down twice as fast), and real ARIA — role="group", a per-box aria-label="Digit N of 6", and one visually-hidden aria-live region so a screen-reader user hears "digit 3 of 6 entered" and "code complete, verified". None of it changes how the widget looks. It's the difference between something that demos and something you can ship.
Try it — type, paste, hit Backspace — and watch the live read-out track every event:
https://dev48v.infy.uk/design/day43-otp-input.html