OTP Input UI: 9 States, Validation Included
An otp input ui is the row of single-digit boxes a signup screen shows for a six-digit code, one character per box.
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Auto-advance to next box
- 02 Backspace focuses previous box
- 03 Paste full code across boxes
- 04 Force numeric mobile keypad
- 05 Masked digit toggle
- 06 Shake and clear on error
- 07 Resend countdown timer
- 08 SMS OTP autofill
- 09 Static focus for reduced motion
The nine below start with the three moves that make six boxes feel like one field: advancing forward, correcting backward, and accepting a full paste in one shot. Items 04 and 05 change what the keyboard and the digits themselves look like. Items 06 and 07 cover the two states a real verification flow can't skip — a wrong code and having to ask for another one. Items 08 and 09 are the idea-form entries, tying the row into what the phone's OS already does with SMS codes and into what a visitor's own reduced-motion setting demands. Watch the grid above cycle through all nine on its own. Each demo below, by contrast, is a live box that genuinely accepts typing, backspacing, and pasting rather than only playing back a loop.
01Auto-advance to next box
Typing a digit into any of the six boxes strips it down to a single number, then moves focus to the next box on its own. A regex on each input event does the sanitizing, and a plain focus() call does the jump. Reach for it in any signup or two-factor screen where the boxes are meant to read as one continuous code, not six separate fields.
boxes.forEach(function (box, i) {
box.addEventListener('input', function () {
box.value = box.value.replace(/[^0-9]/g, '').slice(-1);
if (box.value && boxes[i + 1]) {
boxes[i + 1].focus({ preventScroll: true });
}
});
});
02Backspace focuses previous box
A backspace on an already-empty box isn't ignored. It jumps back to the previous box and clears it, so fixing a mistyped digit never strands the cursor on a dead field. The jump only fires when the current box is already empty; otherwise backspace deletes normally.
boxes.forEach(function (box, i) {
box.addEventListener('keydown', function (e) {
if (e.key === 'Backspace' && !box.value && boxes[i - 1]) {
boxes[i - 1].focus({ preventScroll: true });
}
});
});
03Paste full code across boxes
A paste event on any of the six boxes is caught before the browser inserts anything, stripped down to digits, and split one character per box. That's what turns a code copied out of a mail app into a filled row in one motion. Without it, a single digit would land in a field capped at a maxlength of 1 instead of the whole code.
box.addEventListener('paste', function (e) {
var text = (e.clipboardData || window.clipboardData)
.getData('text').replace(/[^0-9]/g, '');
if (!text) return;
e.preventDefault();
text.slice(0, 6).split('').forEach(function (ch, k) {
if (boxes[k]) boxes[k].value = ch;
});
(boxes[Math.min(text.length, 6) - 1] || boxes[0]).focus({ preventScroll: true });
});
04Force numeric mobile keypad
On a phone, inputmode="numeric" paired with pattern="[0-9]*" opens the number pad instead of the full keyboard, without changing the input's underlying type, which stays text. Both attributes matter together — drop the pattern and some browsers fall back to a keyboard with letters one tap away.
<input
type="text"
inputmode="numeric"
pattern="[0-9]*"
maxlength="1"
autocomplete="one-time-code"
aria-label="Verification code, digit 1"
>
05Masked digit toggle
Tapping the eye button flips every box between type="password" and type="text" at once, turning a row of dots into the digits themselves and back again on a second tap. It behaves the way a banking app's account PIN field does, where digits stay hidden by default but a viewer can confirm what they actually typed.
var masked = true;
toggle.addEventListener('click', function () {
masked = !masked;
boxes.forEach(function (box) {
box.type = masked ? 'password' : 'text';
});
toggle.setAttribute('aria-pressed', String(!masked));
});
06Shake and clear on error
Clicking confirm compares the six digits against the expected code, and a mismatch adds a class that shakes the row while a status message announces the failure. Once that shake animation ends, the boxes clear themselves and focus returns to the first one. The reset only runs after animationend fires, so the shake and the clearing never land on the same frame.
confirmBtn.addEventListener('click', function () {
var code = boxes.map(function (box) { return box.value; }).join('');
if (code.length < 6 || code !== '123456') {
pin.classList.add('is-error');
pin.addEventListener('animationend', function clear() {
pin.classList.remove('is-error');
boxes.forEach(function (box) { box.value = ''; });
boxes[0].focus({ preventScroll: true });
pin.removeEventListener('animationend', clear);
});
}
});
07Resend countdown timer
The resend button starts disabled with a 60-second count spelled out in its own label. A setTimeout loop then ticks that number down once a second until it hits zero and re-enables the button. Clicking resend while the timer is still running does nothing, because the disabled attribute is what actually blocks the click, not just its faded appearance.
var left = 60;
function tick() {
resend.textContent = left > 0 ? 'Resend in ' + left + 's' : 'Resend';
resend.disabled = left > 0;
if (left > 0) {
left--;
setTimeout(tick, 1000);
}
}
08SMS OTP autofill
Every one of the six boxes carries autocomplete="one-time-code", not only the first one, because iOS and Android don't know ahead of time which box will be focused once the SMS code becomes available for autofill. Once the OS drops the code in, it lands through the same input event — or the same paste path used for a manual paste — that already sanitizes and distributes it across the row.
<input maxlength="1" autocomplete="one-time-code">
<input maxlength="1" autocomplete="one-time-code">
<input maxlength="1" autocomplete="one-time-code">
<input maxlength="1" autocomplete="one-time-code">
<input maxlength="1" autocomplete="one-time-code">
<input maxlength="1" autocomplete="one-time-code">
09Static focus for reduced motion
With prefers-reduced-motion set to reduce, the caret that normally slides across the row and the ghost digits that fade in one at a time both turn off. Focus then jumps straight to the next box the instant a digit lands. It's the same auto-advance from Item 01, just stripped of every transition that isn't the focus change itself.
@media (prefers-reduced-motion: reduce) {
.pin__ghost, .pin__caret, .pin__signal, .pin__row, .pin__input {
animation: none !important;
transition: none !important;
}
.pin.is-demo .pin__ghost { opacity: 1; }
}
Where it breaks — the traps
The two demos that quietly depend on the same handful of lines are Items 03 and 08. Neither a browser's clipboard nor a phone's SMS-reading layer delivers a code as six separate keystrokes; both drop the whole string into whichever single box currently has focus. A plain maxlength of 1 would also clip that string down to its first character. Skip the paste listener on any one of the six boxes — not only the first. Both a manual paste and an OS autofill then degrade to one stray digit sitting in that box while the rest stay empty. Neither iOS nor Android promises which box will be focused when the code arrives. Every fix already sits inside the nine demos above, and the whole set — vanilla markup and React versions alike, with nothing extra to install — is worth pulling from the archive instead of retyping nine event handlers by hand; unzip it with 39kzjrs9 typed exactly as it reads in this sentence, no extra spaces added.
Accessibility
None of the nine ignore prefers-reduced-motion, but each drops a different piece of motion rather than freezing solid. Items 01, 02, 04, and 09 lose the sliding caret and the one-by-one ghost fade. The six digits that would normally flash in sequence show up at once instead. Focus then jumps to the next box with no transition at all — exactly what Item 09 exists to demonstrate on its own. Item 05's dot-to-digit blink stops entirely and settles on the plain digit rather than the dot. Item 06 loses its shake, so a wrong code is reported through the status text and a static red icon instead of motion. Item 07's countdown numbers and resend button stop animating in and simply reflect whatever state they're already in. MDN documents prefers-reduced-motion and the operating-system settings that switch it on, and all nine SCSS files share the same media query rather than shipping nine different ones.
The rest of the input-pattern collection lives under the UI category hub, and what this site actually sells — and doesn't — is explained on the about page.
FAQ
Does autocomplete="one-time-code" work the same in every browser?
Mostly, but not identically. MDN's autocomplete reference documents one-time-code as the token that lets a browser or the OS's SMS-reading layer offer to fill a field with an incoming code. Both Safari on iOS and Chrome on Android honor it, though the autofill UI itself — a QuickType suggestion bar versus a keyboard-adjacent chip — differs between them. Set the attribute on every box in a split layout, not only the first, since neither OS guarantees which box will be focused when the code arrives.
Do I need both inputmode="numeric" and pattern="[0-9]*" on every box?
Yes, for two different reasons. The inputmode="numeric" attribute is what tells a mobile browser to show the number pad instead of the full keyboard. The pattern="[0-9]*" attribute is an older iOS-specific check that Safari still looks for before it commits to that numeric layout. Dropping either one can bring letters back within a tap of the code. Neither attribute changes what actually gets accepted — the input event handler still has to strip non-digits itself.
Do these need a framework or a component library to use?
No. Each of the nine compiles from plain SCSS variables into ordinary CSS plus a small vanilla script with no build step. Any one of them drops into a static page as is. The zip's react/ folder wraps the same nine behaviors as typed components for a project that already runs React. Each component takes props for the box count, the completion callback, and the accent color in place of hand-edited class names.