The six-box verification code input looks tidy and fails in every non-typing path: iOS and Android SMS autofill put the whole code in the first box, pasting “482 913” from an email scatters or truncates it, screen readers announce six unlabelled fields, and Backspace in an empty box moves focus somewhere unexpected.

A single input with the right attributes accepts SMS autofill, paste, password-manager fills and typing without special handling — and can still look like separate boxes. This page, part of keyboard navigation patterns, builds that input, adds the WebOTP API where available, and handles errors, expiry and resending accessibly.


Context and prerequisites

The platform features:

  • autocomplete="one-time-code" — Safari on iOS and macOS offers codes from Messages (and Mail) above the keyboard; Chrome on Android offers codes from SMS. The whole code fills one field.
  • WebOTP API — navigator.credentials.get({ otp: { transport: ["sms"] } }) in Chromium on Android reads a code from an SMS whose last line is formatted @your.domain #123456, with user consent.
  • inputmode="numeric" — digit keyboard on mobile without type="number" quirks; pattern="\d{6}" documents the format.
  • Rate limits and expiry — codes expire; resends are limited. Both need visible, accessible states.

The six-input pattern fights these features: autofill targets one field, paste targets the focused field, and each box is a separate form control with its own label and focus stop.

One input versus six boxes With a single input, SMS autofill fills the whole code, pasting works natively, password managers can fill it, a screen reader announces one labelled field, and Backspace behaves normally. With six separate inputs, SMS autofill puts the whole code in the first box or fails, paste needs custom distribution code, password managers often fail, screen readers announce six fields that each need labels, and Backspace needs custom focus-moving code. Behaviour One input Six boxes SMS autofill whole code first box only, or fails paste "482 913" native custom distribution code password managers fill often fail screen reader one labelled field six fields to label Backspace native custom focus handling

The core pattern: one input, segmented look, WebOTP enhancement

<form id="verify" novalidate>
  <label for="otp">Enter the 6-digit code we sent to 07700 900123</label>
  <p id="otp-hint" class="hint">The code expires in 10 minutes.</p>
  <input id="otp" name="otp"
         inputmode="numeric" autocomplete="one-time-code"
         pattern="\d{6}" maxlength="12"
         aria-describedby="otp-hint otp-error" class="otp">
  <p id="otp-error" class="error" hidden></p>
  <button type="submit">Verify</button>
  <button type="button" id="resend">Send a new code</button>
  <p id="resend-status" role="status"></p>
</form>
/* Visual segmentation without separate inputs: monospace + letter-spacing,
   with a background of six cells. */
.otp {
  font: 600 1.5rem/1 ui-monospace, monospace;
  letter-spacing: 0.9em;
  padding-left: 0.45em;
  width: calc(6 * 1.5em + 1em);
  background:
    repeating-linear-gradient(90deg, transparent 0 calc(1.5em - 2px), #cbb8d9 calc(1.5em - 2px) 1.5em)
    bottom / 100% 2px no-repeat;
}
const input = document.querySelector<HTMLInputElement>("#otp")!;

// Normalise whatever arrives (typed, pasted "482 913", "482-913", autofilled).
input.addEventListener("input", () => {
  const digits = input.value.replace(/\D/g, "").slice(0, 6);
  if (digits !== input.value) input.value = digits;
  if (digits.length === 6) input.form?.requestSubmit();   // optional: submit when complete
});

// WebOTP where supported (Chromium on Android). Abort if the user leaves.
if ("OTPCredential" in window) {
  const ac = new AbortController();
  input.form?.addEventListener("submit", () => ac.abort(), { once: true });
  navigator.credentials
    .get({ otp: { transport: ["sms"] }, signal: ac.signal } as CredentialRequestOptions)
    .then((cred) => {
      const code = (cred as unknown as { code?: string })?.code;
      if (code) { input.value = code; input.form?.requestSubmit(); }
    })
    .catch(() => { /* user declined or aborted: typing still works */ });
}

Step-by-step walkthrough

  1. Use one <input> with autocomplete="one-time-code" and inputmode="numeric". SMS autofill, paste and password managers all work without extra code.
  2. Label it with where the code went. “Enter the 6-digit code we sent to 07700 900123” tells users what to expect and which device to check.
  3. Normalise input, do not restrict it. Strip spaces and dashes from pasted codes; allow a generous maxlength so formatted pastes are not truncated before normalising.
  4. Style segmentation with CSS. Monospace text and cell backgrounds give the six-box look while the element remains one field for assistive technology.
  5. Enhance with WebOTP on supporting browsers. It reads the code from a correctly formatted SMS with the user’s consent; abort it when the form is submitted or the user navigates away.
  6. Handle errors, expiry and resend in text. “That code is not correct. Check the latest message, or send a new code.” Link the error with aria-describedby; announce resend results through a status region.

Why auto-submit on the sixth digit needs care

Submitting automatically when six digits are present is convenient, especially with autofill — but it removes the user’s chance to check what they entered, and for screen-reader users the page can change before they hear the last digit echoed. A good compromise: auto-submit only when the value arrived in one step (autofill, paste, WebOTP), and wait for an explicit Verify press when the user types digit by digit. If you auto-submit always, make sure a failed verification returns focus to the input with the error, and never clears the field silently.

Three ways the code arrives, one field With SMS autofill, the keyboard suggestion inserts 482913 into the field in one input event, which is normalised and submitted. With paste, the user pastes 482 913 from an email; normalisation strips the space and the form submits. With typing, digits arrive one at a time; normalisation keeps them, and the user presses Verify when all six are entered. All three paths use the same field and the same validation. SMS / clipboard / keys Input Form autofill: "482913" (one event) 6 digits → requestSubmit() paste: "482 913" → "482913" 6 digits → requestSubmit() typing: 4, 8, 2, 9, 1, 3 user presses Verify

Keeping the rest of the page out of the way

A verification step is short, but users switch away from it — to their messages, their email, their authenticator app — and come back. Design for that round trip: keep the code field focused when the page regains visibility only if it was focused before, do not reset the form on visibilitychange, and avoid timers that silently expire the page while the user is reading their messages. If the code expires while they are away, say so when they return, next to the field, rather than letting them type a code that can no longer work. Small details like these decide whether the step takes ten seconds or ends in a support ticket.


Failure modes and edge cases

1. type="number"

Number inputs drop leading zeros (“012345” becomes 12345), show spinners and ignore maxlength. Codes are strings; use type="text" with inputmode="numeric".

2. Clearing the field on error

Wiping the input after a wrong code forces retyping and hides what was entered. Keep the value, mark it invalid, and let the user correct it — or explicitly select it so typing replaces it.

3. Expiry without warning

A code that expires silently produces “incorrect code” errors for a correct code. Show the expiry time in the hint, say “This code has expired” specifically, and offer a new code in the same message.

4. Resend flooding

Unlimited resends invite abuse and confuse users with several valid-looking codes. Rate-limit, show when the next resend is available (“You can request a new code in 30 seconds”) in text that updates without constant announcements — the throttling from throttling live region announcements.

5. The SMS format for WebOTP

WebOTP only works if the message’s last line is @your.domain #code for the exact origin. Coordinate with whoever sends the SMS; without the line, autocomplete="one-time-code" still helps on iOS and in Chrome’s keyboard suggestions.

The states of a verification step While waiting, the label says where the code was sent and the hint gives the expiry time. After an incorrect code, the value is kept, the field is marked invalid, the error says the code is not correct and to check the latest message, and focus stays in the field. After expiry, the error says the code has expired and offers to send a new one. For resend, a status message confirms a new code was sent, or says when the next resend will be available. Waiting Label: where it was sent. Hint: expires in 10 min. Incorrect Value kept; aria-invalid. "Check the latest message." Expired "This code has expired." Send a new one. Resend "We've sent a new code." Or: available in 30 s.

Verification checklist


Frequently Asked Questions

Can I keep six boxes if the design requires them?

You can keep the look with the CSS approach above while using one input. If separate inputs are unavoidable, each needs a label (“Digit 1 of 6”), paste must distribute across them, autofill into the first box must be split, and Backspace must move focus backward — substantially more code, still less robust.

Should the input use aria-live for each digit?

No. Screen readers echo typed characters already. Announce only outcomes — verified, incorrect, expired, new code sent.

Is SMS the right channel for codes?

SMS codes are widely used but vulnerable to SIM-swap and interception. Authenticator apps and passkeys are stronger; offer them where possible, and keep SMS as a fallback with the accessible input described here.


Related

← Keyboard Navigation Patterns