An OTP input from scratch: six boxes that act like one field — auto-advance, backspace-jump, and paste splits across all six

# javascript# webdev# css# frontend
An OTP input from scratch: six boxes that act like one field — auto-advance, backspace-jump, and paste splits across all sixDevanshu Biswas

You 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.

Render the boxes from one number

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("");
Enter fullscreen mode Exit fullscreen mode

Only digits, and auto-advance

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 is the rule everyone gets wrong

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
}
Enter fullscreen mode Exit fullscreen mode

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.

Paste is where naive fields fall apart

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
});
Enter fullscreen mode Exit fullscreen mode

Fire complete exactly once

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