Input OTP
The field for a one-time code. It looks like six boxes and it is one input — the boxes are a costume drawn over a single native control. That distinction is the whole component: paste, autofill, form submission and every screen reader depend on it, and six real inputs break all four.
The component
Sent to ana@example.com. It expires in 10 minutes.
The correct code in this demo is 481902. The paste control sends 481-902, hyphen and all, the way it arrives when copied out of an email — it is sanitised and submitted by the same code path as typing, because there is only one.
One input, not six
Six separate <input maxlength="1"> elements is the implementation everyone writes first, and it fails in five places at once.
| What happens | Six inputs | One input |
|---|---|---|
| Pasting a code | Fills the first box, drops five digits — or needs a paste handler per box | Works. It is a paste into a text field |
| iOS / Android autofill | Fires into one box; the OS has no way to distribute the rest | The OS fills all six |
| Screen reader | “Edit text, blank” six times, with no sense of position or length | One labelled field: “6-digit code” |
| Backspace at a boundary | Needs hand-written focus juggling, and gets it wrong at the ends | Native |
| Form submission | Six values to concatenate, six to validate, six to clear | One value |
The slots are <div>s with aria-hidden behaviour by omission — they carry no role, no tabstop and no text of their own that a screen reader needs. The input sits on top at opacity: 0, full width, so a tap anywhere on the row focuses it.
The autofill contract
Four attributes, and the component is worth building only if all four are right. This is the difference between a code that appears above the keyboard and a code the user has to memorise from another app.
| Attribute | Value | What it does |
|---|---|---|
autocomplete | one-time-code | iOS and Android surface the code from the SMS or email above the keyboard. Without it, nothing is offered |
inputmode | numeric | A numeric keypad, not a full keyboard |
pattern | [0-9]* | Older iOS reads this, not inputmode. Cheap insurance |
type | text — never number | number adds spinners, accepts e and -, and drops leading zeros. A code is a string of digits, not a quantity |
The email must carry the code in its subject line too. Platform autofill reads it from the notification. A code buried in the body of an HTML email is a code the user has to go and find.
Behaviour
| Action | Result |
|---|---|
| Typing | Fills left to right. Non-digits are dropped silently — pasting 481-902 gives 481902 |
| Backspace | Clears the last filled slot. No focus juggling, because there is one field |
| Paste | Fills every slot it can and stops at six |
| Complete | Submits automatically. The user has nothing left to decide, and a Continue button below a full code is a second thing to press |
| Wrong code | The digits stay, selected. The message says what to do. Next keystroke replaces the lot |
| Correct code | Slots turn green, then the flow moves on |
Clearing the field on a wrong code is the most common mistake in this component. One mistyped digit costs the user all six, and they cannot see what they got wrong. Keeping the value and selecting it costs nothing and preserves the evidence.
Resend and expiry
An OTP screen is a dead end until the code arrives, so the escape hatch is part of the component, not an afterthought below it.
Offer resend immediately, with a 30-second cooldown after each use.
Count the cooldown down in the label, so waiting is visibly finite.
- State the destination. “Sent to ana@example.com” — half of failed verifications are a code sent somewhere the user cannot read.
- State the expiry once, in minutes. Not a live countdown: a ticking clock next to a field is pressure, and codes that expire while being typed are a server problem, not a UI one.
- Never disable resend behind a timer with no label. A dead button with no explanation is indistinguishable from a broken one.
- Offer a way back. “Use a different email” — the code may be going to an address with a typo in it, and no number of resends fixes that.
Specification
| Property | Value | Why |
|---|---|---|
| Length | 6 digits | One length across the product. Mixed lengths mean the user has to count |
| Alphabet | Digits only | Alphanumeric codes force a full keyboard and raise misreads (0/O, 1/l) |
| Slot | 44×52px, --radius-md, 1px --color-border-strong | 44px wide meets the target minimum even though the target is the whole row |
| Digit | 20px, weight 500, tabular numerics | Tabular so digits do not shift the row as they land |
| Group separator | A 12px hairline after slot 3 | Chunking 3+3 measurably reduces transcription errors |
| Active slot | --color-border-focus plus a blinking caret | The real caret is hidden, so the component draws its own |
| Filled slot | Border darkens to --color-text-primary | Progress is legible without colour alone carrying it |
| Error | All six borders --color-destructive + a message | The system never signals an error with fill |
| Never | Masked digits | A code is not a password. It is on screen in another app two centimetres away |
Accessibility
- The input is labelled “6-digit code”, not “Code”. The length is the one thing a non-sighted user cannot see.
- The message is
aria-describedbyandrole="status", so the destination, the expiry and any error all reach the field's description — and changes are announced without stealing focus. - Slots are decoration. They carry no role and no
aria-label. Addingrole="textbox"to each one recreates the six-input problem in ARIA. - Auto-submit is announced — “Checking your code” in the status region — because a form that submits itself with no announcement leaves a screen-reader user waiting on a page that already moved.
- The caret blink respects
prefers-reduced-motion, resolving to a static bar.
Markup
<div class="otp">
<span class="otp__lbl" id="otp-label">6-digit code</span>
<div class="otp__row">
<div class="otp__slot"></div><div class="otp__slot"></div><div class="otp__slot"></div>
<span class="otp__sep" aria-hidden="true"></span>
<div class="otp__slot"></div><div class="otp__slot"></div><div class="otp__slot"></div>
<!-- the only real control: transparent, on top, full width -->
<input class="otp__input" type="text" maxlength="6"
inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code"
autocapitalize="off" autocorrect="off" spellcheck="false"
aria-labelledby="otp-label" aria-describedby="otp-msg">
</div>
<p class="otp__msg" id="otp-msg" role="status">Sent to ana@example.com. It expires in 10 minutes.</p>
</div>The slots render from the input's value; they hold no state of their own. If the two can ever disagree, the component has been built wrong.
theme.css.