Components · Input OTP
Components

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

6-digit code

Sent to ana@example.com. It expires in 10 minutes.

Didn't get it?
Try

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 happensSix inputsOne input
Pasting a codeFills the first box, drops five digits — or needs a paste handler per boxWorks. It is a paste into a text field
iOS / Android autofillFires into one box; the OS has no way to distribute the restThe OS fills all six
Screen reader“Edit text, blank” six times, with no sense of position or lengthOne labelled field: “6-digit code”
Backspace at a boundaryNeeds hand-written focus juggling, and gets it wrong at the endsNative
Form submissionSix values to concatenate, six to validate, six to clearOne 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.

AttributeValueWhat it does
autocompleteone-time-codeiOS and Android surface the code from the SMS or email above the keyboard. Without it, nothing is offered
inputmodenumericA numeric keypad, not a full keyboard
pattern[0-9]*Older iOS reads this, not inputmode. Cheap insurance
typetext — never numbernumber 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

ActionResult
TypingFills left to right. Non-digits are dropped silently — pasting 481-902 gives 481902
BackspaceClears the last filled slot. No focus juggling, because there is one field
PasteFills every slot it can and stops at six
CompleteSubmits automatically. The user has nothing left to decide, and a Continue button below a full code is a second thing to press
Wrong codeThe digits stay, selected. The message says what to do. Next keystroke replaces the lot
Correct codeSlots 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.

Didn't get it?
Do

Offer resend immediately, with a 30-second cooldown after each use.

Didn't get it?
Do

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

PropertyValueWhy
Length6 digitsOne length across the product. Mixed lengths mean the user has to count
AlphabetDigits onlyAlphanumeric codes force a full keyboard and raise misreads (0/O, 1/l)
Slot44×52px, --radius-md, 1px --color-border-strong44px wide meets the target minimum even though the target is the whole row
Digit20px, weight 500, tabular numericsTabular so digits do not shift the row as they land
Group separatorA 12px hairline after slot 3Chunking 3+3 measurably reduces transcription errors
Active slot--color-border-focus plus a blinking caretThe real caret is hidden, so the component draws its own
Filled slotBorder darkens to --color-text-primaryProgress is legible without colour alone carrying it
ErrorAll six borders --color-destructive + a messageThe system never signals an error with fill
NeverMasked digitsA 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-describedby and role="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. Adding role="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.

Sporting Scouter Design System
Every token on this site is generated from theme.css.