Design an OTP input
The 6-digit verification code field shown after a code arrives by SMS, email, or an authenticator app. It looks like six boxes, but the hard parts are hidden: one real input versus six, autofill from the keyboard and the WebOTP API, paste with spaces and dashes, auto-submit without double submits, wrong codes, attempts, a resend cooldown, and a field a screen reader can use.
The question
Design the one-time code input for our design system. After sign-in, sign-up, or a risky action, we send a 6-digit code by SMS or email, or ask for one from an authenticator app. The user types it on this screen.
Walk me through how the field is built, how codes get in (typing, paste, autofill), when it submits, what happens on a wrong code, how resend works, and how a keyboard and screen reader user gets through it.
Real-life examples: The code screen after signing in to a bank or Stripe, WhatsApp and Telegram phone number checks, Sign-in codes sent by email in Slack and Notion, Two-factor codes from Google Authenticator or 1Password, The input-otp React library behind shadcn/ui's OTP field
Requirements: what to ask before drawing six boxes
A one-time password (OTP), also called a verification code, is a short code that proves you hold something: a phone number, an inbox, or an authenticator app. It works once and expires in minutes. The OTP input is the field where you type it. You've used it after signing in to your bank, when WhatsApp checks your number, or when Slack emails you a sign-in code.
The auth-flow guide covers where this screen sits in sign-in and multi-factor authentication (MFA), and why SMS codes are weaker than passkeys: see its chapter "MFA, step-up and remembered devices". This guide zooms in on the component itself. It is a small widget, but it has to accept a code from five different sources, submit at the right moment, and work for a screen reader user who never sees the boxes.
Start with questions. Each answer changes the design. The middle column is what this guide assumes.
| Question | Answer we assume | What it changes |
|---|---|---|
| How long is the code? Digits only? | 6 digits by default. Some teams want 4 or 8, and one product uses 6 letters and digits. | length and an allowed-characters rule are props, not constants. The keyboard hint changes with them. |
| Where does the code come from? | SMS, email, and authenticator apps (TOTP, a code that changes every 30 seconds). | Autofill matters most for SMS. Email users copy and paste. Authenticator users often paste from a password manager. |
| Which devices? | Mostly phones (iOS Safari and Chrome on Android), plus desktop browsers. | The numeric keypad, keyboard code suggestions, and the WebOTP API all shape the field. |
| Submit when the last digit is typed? | Yes, with a visible button as well. | We need a guard against double submits and a plan for screen reader users. |
| How many wrong tries are allowed? | 5 per code, then the code is dead and the user must request a new one. | The UI shows attempts left and a locked state. The server enforces the limit. |
| How does resend work? | A new code after a 30 second cooldown, growing to 60 and 120 seconds. At most 5 sends per hour. | A countdown that follows the server's numbers, not a client guess. |
| How long is a code valid? | 10 minutes, single use. | An expired state and a clear message, not a generic error. |
| Accessibility target? | WCAG 2.2 AA, including success criterion 3.3.8 Accessible Authentication. | Paste and autofill can never be blocked. One label, spoken errors, no time pressure on typing. |
Functional requirements
- Show a field for an N-character code that looks like N boxes, with the active box highlighted.
- Accept the code by typing, by pasting the whole code (even with spaces or a dash, like
123 456or123-456), and by autofill from the keyboard, a password manager, or the WebOTP API. - Fix mistakes with Backspace, Delete, arrow keys, and select-all, the way a text field normally behaves.
- Submit once when the code is complete, and also offer a Verify button.
- Show wrong-code errors, attempts left, an expired code, and a locked state, each with a clear next step.
- Offer Resend code after a cooldown, with a visible countdown.
Non-functional requirements
- Accessible: one labelled field for screen readers, every error spoken, fully usable with a keyboard, and paste always allowed.
- Fast to finish: from code arriving to verified in under 5 seconds when autofill works. Each extra tap is a place users drop off.
- Safe: the code never lands in logs, analytics, or URLs, and nothing on the client pretends to be the security boundary.
- Small: a design system component should weigh a few KB, with no dependency on a form library.
- International: digits stay left to right in right-to-left layouts, and messages are translatable.
Out of scope
Sending the SMS or email, generating and checking the code on the server, and choosing between SMS, email, and passkeys belong to the auth flow. We only need to know the API the screen calls and the errors it returns. Credit card and PIN fields look similar, but they have different rules (a PIN is a secret you remember, so it gets masked).
Check your understanding · Exercise 1
Which answers would change how the component is built, not just its text (select all that apply)? Select all that apply.
What makes a six-box field hard
Draw the screen first, so everyone agrees on the parts. A heading says where the code went ("We sent a code to +1 ••• ••• 4821"), then come the six boxes, a hint or error line under them, a Verify button, and a Resend link with its countdown.
It looks like a beginner exercise. Four things make it harder than it looks, and every later chapter comes back to one of them.
- Many ways in. The same six digits can arrive by typing one at a time, by pasting all at once, by tapping a suggestion above the iOS keyboard, from a password manager, or from the WebOTP API with no typing at all. Each one writes to the field in a different way: one key event per digit, one
pasteevent, or oneinputevent that replaces the whole value. A design that only handles the first way breaks the fastest ways. - The look fights the semantics. Designers want six boxes. Assistive technology, autofill, and the browser's editing commands want one text field with one value. The choice between them (chapter 3) is the biggest decision in the guide.
- One shot, real limits. The server allows a few tries per code and a few sends per hour. A double submit can burn two attempts with one code. A resend button without a cooldown lets an impatient user use up the hourly send limit in seconds and then wait an hour. The client must make these limits visible without pretending to enforce them.
- Time. Codes expire, resend has a cooldown, and some users need more time to read a code from another device. Countdowns have to be correct after a reload and announced without being noisy.
Think first
An engineer tests the field by typing digits on a laptop and says it's done. Which real users would hit a bug first?
iPhone users who tap the code suggestion above the keyboard: Safari inserts all six digits at once into the focused field. If the field is six separate inputs with maxlength="1", the first box may keep one digit and the rest are lost.
Next, email users who copy 482 913 with a space, and password manager users whose extension fills a field it detected by its autocomplete attribute. Typing tests none of these paths.
Check your understanding · Exercise 2
Which events can deliver more than one character to the field at once (select all that apply)? Select all that apply.
The big decision: one field or six
Every OTP design starts here. The naive build is six input elements, each with maxlength="1", wired together with JavaScript: when a box gets a digit, focus jumps to the next one; when Backspace hits an empty box, focus jumps back. It looks right in a demo. Then real input arrives.
- Paste. The user pastes
482913into box 1. Withmaxlength="1", the browser keeps4and drops the rest. You must catch thepasteevent, split the text, and write each digit into a box by hand. The W3C's Understanding document for WCAG 3.3.8 uses this exact bug as its example of a code field that fails. - Autofill.
autocomplete="one-time-code"describes one field that holds the whole code. iOS inserts the full code into whichever box has focus. Put the attribute on the first box only and it works until the user taps box 3 first; put it on all six and you are relying on behavior no browser promises. - Screen readers. VoiceOver and NVDA reach six edit fields. Unless you label each one ("Digit 1 of 6"), they hear something like "edit text, blank" six times. Even with labels, they must move through six stops to check one code, and the value is never read as a whole.
- Editing commands. Undo (Cmd+Z) follows the browser's edit history, and values set from JavaScript as focus jumps between boxes break that history, so clearing a code and pressing Undo rarely brings it back. Select-all selects one digit. Shift with arrow keys can't select a range. Paste from the iOS long-press menu lands in the one box you pressed, where
maxlength="1"cuts it. - Input method editors (IME) and composition, used for Japanese or Chinese text and by some Android keyboards, can deliver characters through
compositionendrather than single key presses, so key-based jumping between boxes gets out of step. - Password managers fill TOTP codes most reliably into one field marked
one-time-code. Split fields depend on each manager's own guesswork.
Every one of these is fixable with code, and teams do fix them one bug report at a time. The question is whether to fight the browser or to keep the one thing it understands: a single text field.
Decision How should the six boxes be built?
What we pick: One real input over drawn boxes. It is the only option that keeps every browser and assistive technology feature for free while giving designers their boxes. If the design allows it, the plain styled input is a fine, smaller fallback. Six wired inputs only make sense when you can't control the markup, and even then they need a long list of fixes.
value and selectionStart.Try both models below. Paste 123 456, simulate an iOS code suggestion, press Backspace and the arrow keys, and watch what each design keeps, loses, and announces.
Pick an action. It is sent to both designs at once.
Behind the drawing, the component owns two pieces of state: the value string and the selection range from the input. Each box i shows value[i], is active when the caret sits at i (or when i equals the value length and the field has focus), and is filled when i is less than the value length. Nothing else is stored. Because the boxes are derived, they can never disagree with the field the form submits.
Check your understanding · Exercise 3
Your team ships six wired inputs. Which bugs will users report (select all that apply)? Select all that apply.
Getting the code in without typing
The fastest code is the one the user never types. Three platform features can fill the field for them, and all three depend on the single-field decision from the last chapter.
autocomplete="one-time-code": keyboard suggestions
The autocomplete attribute tells the browser what a field is for. The token one-time-code says "this field takes a one-time code". On iOS and macOS, Safari reads recent codes from Messages (and, since iOS 17 and macOS Sonoma, from the Mail app) and offers the code in the bar above the keyboard. One tap inserts it.
On Android, Chrome has been adding SMS code suggestions for fields with this token, and keyboards such as Gboard can suggest a code from a recent message; how it looks depends on the Chrome version and the device. Password managers also use the token to decide where to fill a TOTP code.
<label for="otp">Verification code, 6 digits</label>
<input id="otp" name="code"
autocomplete="one-time-code"
inputmode="numeric"
pattern="[0-9]*"
maxlength="6"
aria-describedby="otp-hint otp-error" />
Domain-bound SMS: one format for Safari and Chrome
A plain SMS like "Your code is 482913" can be suggested on any website, including a phishing copy of yours. A domain-bound code adds a last line naming the site the code is for. Safari (since iOS 14 and macOS Big Sur) only offers the code on that domain, and Chrome's WebOTP API only returns it to that origin. One message format serves both, so the backend team should send every SMS this way.
The auth-flow guide's MFA chapter shows the same line. The rules that trip teams up: @ must start the last line, a single space separates the host from #code, and the host has no scheme, port, or path.
Your Acme code is 482913. It expires in 10 minutes.
@acme.com #482913
The WebOTP API: Chromium only, and optional
The WebOTP API goes one step further, and the auth-flow guide's MFA chapter (section "Entering codes without friction") explains where it fits: the page asks for the code, Chrome shows a consent sheet when a matching SMS arrives, and the promise resolves with the code. Chrome on Android supports it since version 84. Since Chrome 93, desktop Chrome can receive codes from the user's Android phone when both are signed in to the same Google account.
What matters for the component: WebOTP needs HTTPS, and Safari and Firefox don't support it. So it is a progressive enhancement. Feature-detect with "OTPCredential" in window inside an effect, and keep the field fully usable without it.
function useWebOtp(onCode: (code: string) => void, enabled: boolean) {
useEffect(() => {
if (!enabled || !("OTPCredential" in window)) return;
const ac = new AbortController();
navigator.credentials
.get({ otp: { transport: ["sms"] }, signal: ac.signal } as CredentialRequestOptions)
.then((cred) => {
const code = (cred as { code?: string } | null)?.code;
if (code) onCode(code);
})
.catch(() => {}); // aborted, denied, or timed out: typing still works
return () => ac.abort();
}, [enabled, onCode]);
}
The result goes through the same path as a paste: sanitize, set the value, and let the completion rule decide whether to submit. Don't write input.value directly from outside React, or the component's state and the DOM will disagree.
Email codes and authenticator apps
Email codes have no keyboard bridge on most platforms (Safari with Apple's Mail app is the exception), so users switch apps, long-press, copy, and paste. Two things help: put the code on its own line in the email with nothing else to select, and accept the paste even if it has spaces or a trailing period. Many email services also send a magic link alongside the code; that's an auth-flow decision.
Authenticator codes (TOTP) change every 30 seconds. Users read them on another device or paste them from a password manager like 1Password, which fills them into a one-time-code field.
Check your understanding · Exercise 4
Which statements about getting codes in are true (select all that apply)? Select all that apply.
Input behavior: keyboard, filtering, and paste
Why not type="number"
A code is made of digits, so type="number" seems natural. It is the wrong tool. A number input is for quantities, and a code is a string that happens to use digits.
Problem with type="number" | What the user sees |
|---|---|
maxlength is ignored on number inputs. | Nothing stops a seventh digit. |
It accepts e, +, -, and . as parts of a number. | 1e5 is a valid number, so filtering gets messy. |
| Arrow Up and Down step the value, and in some browsers the scroll wheel does too. | A user scrolling the page changes 004821 into 4822. |
Code that reads valueAsNumber or converts with Number() drops leading zeros. | 012345 becomes 12345 and fails to verify, 1 time in 10. |
| Desktop browsers draw spinner arrows. | Extra controls that make no sense for a code. |
Use type="text" with inputmode="numeric". The inputmode attribute only picks the virtual keyboard: iOS and Android show a digits keypad, and the value stays a string. pattern="[0-9]*" was once needed to get the big keypad on iOS before inputmode arrived in iOS 12.2; today it mainly gives native validation a rule, so keep it for plain forms.
maxlength="6" stops the browser from accepting a seventh character. For alphanumeric codes, switch to inputmode="text" with autocapitalize="characters" and spellcheck="false".
One sanitize function for every source
Users paste 482 913, 482-913, Code: 482913., or digits copied with a stray newline. Some keyboards, such as Japanese and Chinese input methods, even type full-width digits like 482. Don't reject these. Clean them with one function that every path calls: typing, paste, autofill, and WebOTP.
const DIGIT = /[0-9]/;
export function sanitize(raw: string, length: number, allowed = DIGIT): string {
return raw
.normalize("NFKC") // full-width 4 becomes 4
.split("")
.filter((ch) => allowed.test(ch))
.slice(0, length)
.join("");
}
// "Code: 482-913." -> "482913"
// "482 913" -> "482913"
// "1234567" -> "123456"
Where you call it matters. In the input event, the browser has already changed the value; you read it, sanitize it, and set the cleaned value back. In the paste event, maxlength would cut 482 913 to 482 91 before your code sees it, so read event.clipboardData.getData("text"), call preventDefault(), sanitize, and insert the result at the selection. The tag-input guide splits pasted text into many values; here the opposite happens, and everything collapses into one.
Keys and selection
With one real input, the browser already handles Backspace, Delete, arrow keys, Home, End, select-all, and undo. The component's job is to make the boxes agree. Two small rules make the field feel like boxes rather than a text line:
- Typing over a filled box replaces it. When the caret is in a filled box, select that one character, so the next digit replaces it instead of pushing the others right. input-otp does this by turning the caret into a one-character selection on every
selectionchange, except at the end of the value, where a plain caret means "add the next digit here". - Focus selects all, or puts the caret at the end. After a wrong code, focusing the field with everything selected lets the user type the new code straight over the old one.
If you must build six inputs anyway (say, a legacy page you can't restructure), the list grows: move focus forward after each digit, move back on Backspace in an empty box, handle Left and Right arrows, select the box's content on focus, split paste across boxes, and keep a hidden input with the joined value for the form. Each is a few lines; together they are where most OTP bugs live.
Check your understanding · Exercise 5
A user pastes Your code: 012-345 into an empty field. What should the field hold? Pick one.
Submitting once, and what happens on a wrong code
Auto-submit: when the last digit lands
Most OTP screens verify as soon as the sixth digit arrives. With autofill, that turns "tap the suggestion, tap Verify" into one tap. But it has costs, so weigh it.
Decision When should the code be sent to the server?
What we pick: Auto-submit when complete, with a visible button, as the design system default, and a prop to turn it off. Spending one attempt on a typo is cheap when the limit is 5 and the field keeps the code for a quick fix. Teams with strict limits (3 tries) can switch to the one-shot rule.
Auto-submit creates the classic double-submit bug. The WebOTP promise resolves and sets the value; the onChange reaches 6 digits and calls verify; the user, seeing the code appear, presses Enter. That's two requests with the same code.
If the first succeeds and the second arrives after, the server says "code already used", and a fast success turns into a red error. The fix is a small state machine: only the entering state can start a verify, and only once per value.
onComplete or Enter arrives while the state is verifying, and no arrow leaves verifying on that event.- entering: the user edits. When the value is complete and differs from the last sent value, move to
verifying. - verifying: the request is in flight. The field is
readOnly(notdisabled, which would drop focus and take the field out of the tab order, so a screen reader user loses their place), the button shows a spinner, and Enter does nothing. - entering, with an error: the server said no (
400). Stay inentering, show why and the tries left, and keep or clear the value (below). The error clears on the next edit. - expired and locked: the code can't succeed anymore. Hide the Verify button and lead with Send a new code.
- rateLimited: the server refused for a while (
429). The field is read-only and the message says when to come back. - success: hand off to the auth flow. Don't unlock the field again.
Error messages that say what to do next
| Server says | Message | Field |
|---|---|---|
INVALID_CODE with attemptsLeft: 3 | That code isn't right. 3 tries left. | Keep the value, select all, focus. |
INVALID_CODE with attemptsLeft: 1 | That code isn't right. 1 try left before you need a new code. | Same, with stronger wording. |
CODE_EXPIRED | This code has expired. Send a new one. | Clear, read-only, focus the Send button. |
TOO_MANY_ATTEMPTS | Too many wrong codes. Send a new code to try again. | Clear, read-only, focus the Send button. |
RATE_LIMITED with retryAfter: 900 | Too many tries. You can try again in 15 minutes. | Read-only, no resend until the time passes. |
| Network error or 5xx | We couldn't check your code. Try again. | Keep the value. No attempt was spent as far as we know. |
Keep or clear after a wrong code? Keep it, selected. The user can see the code and compare it with the message, often spotting a swapped pair of digits, and typing replaces the whole selection.
Clearing forces them to re-enter everything and hides their mistake. Clear only when the code itself is dead (expired or locked), because then no edit can fix it.
Many products shake the boxes on a wrong code. It's a good signal, but motion can make some people dizzy, so wrap it in @media (prefers-reduced-motion: no-preference) and use a color and icon change for everyone. The shake is never the only signal: the text message carries the meaning, as the visual-accessibility lesson explains.
Try the whole loop in the lab below: type or autofill the code, get one wrong, watch attempts drop, hit the lock, and wait out the resend cooldown.
Code sent to the phone ending 4821.
Check your understanding · Exercise 6
WebOTP fills the code and the user presses Enter at the same moment. Which designs prevent a second verify (select all that apply)? Select all that apply.
The resend timer
SMS is slow and sometimes lost. Users who don't get a code within a few seconds tap Resend, then tap it again. Every send costs money (more in some countries than others), and bots that trigger codes to premium-rate numbers, called SMS pumping fraud, turn an open Resend button into a bill. Every extra code is also another chance for an attacker who controls the number.
So resend has a cooldown: the button stays off for a while after each send, and a countdown says when it comes back.
The naive version is a client timer: setTimeout for 30 seconds, then enable the button. It breaks in three ways. A reload resets it to 30 seconds, or to zero.
The server also has its own limits (5 sends per hour, say) that the client doesn't know about. And browsers throttle timers in background tabs, so the countdown can freeze and then jump.
Decision Who decides when Resend becomes available?
What we pick: Server times with a client display. The server must enforce the limits anyway, so it should also publish them. The client's only job is to show the countdown honestly and to recompute it from the target time, never from a counter it decrements.
Every send response carries resendAvailableAt (an absolute time) and sendsLeft. The client only displays them: remaining seconds are resendAvailableAt minus the current time, adjusted by the clock offset measured from the response's Date header, recomputed on each tick. That's the same approach the travel-booking guide uses for its price-hold countdown, and it survives reloads because the page re-reads the challenge from the server.
resendAvailableAt with each code, so a reload shows the same remaining time.function useCountdown(targetMs: number | null, offsetMs: number) {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (targetMs === null) return;
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, [targetMs]);
if (targetMs === null) return 0;
// offsetMs = serverTime - clientTime, from the response Date header
return Math.max(0, Math.ceil((targetMs - (now + offsetMs)) / 1000));
}
How long to wait
Grow the cooldown with each send: 30 seconds, then 60, then 120. A 30 second first wait covers a slow but normal delivery, and a growing wait slows down someone hammering the button without punishing the first retry. After the hourly limit, the server returns RATE_LIMITED with retryAfter, and the UI says when to come back instead of showing a dead button with no reason.
Announcing the countdown without noise
A screen reader that reads "Resend in 29 seconds, 28 seconds..." every second is unusable. Render the visible countdown in plain text with no live region. Announce only two moments through a polite live region: "Code sent to +1 ••• ••• 4821" after a send, and "You can request a new code now" when the cooldown ends.
Keep the Resend button in the tab order with aria-disabled="true" during the cooldown, and give it a name that says why: "Resend code, available in 30 seconds". A truly disabled button can't be focused, so keyboard users never learn it exists.
Check your understanding · Exercise 7
A user sends a code, waits 10 seconds, and reloads. What should the Resend button show (select all that apply)? Select all that apply.
Architecture, state, and the API
Split the work into a dumb input and a smart screen. The design system ships the input; each product's auth team builds the screen around it. The input knows nothing about servers, and the screen knows nothing about carets.
| Part | Responsibility | Why the boundary is here |
|---|---|---|
OtpInput | One real input, sanitize, selection tracking, drawing the slots, onComplete. | Reused by sign-in, sign-up, payment checks, and settings. It must not know which API it serves. |
OtpSlot (render slot) | Draws one box from char, isActive, hasCaret, isError. | Products restyle boxes, add a dash after 3, without forking the logic. |
VerifyScreen | Owns the state machine, error messages, attempts left, and focus after errors. | Business rules differ per flow; this is where they live. |
ResendControl | Countdown from resendAvailableAt, button state, announcements. | Same control under SMS and email; it only needs the challenge. |
useWebOtp | Optional WebOTP request with abort. | Chromium-only, so it's a separate enhancement that can be left out. |
otpApi client | verify, resend, parsing error codes, clock offset from Date. | One place that maps server errors to the state machine's events. |
OtpInput and OtpSlot; each product owns the screen, the resend control, and the API client. onComplete flows back up from the input to the screen, which is the only part allowed to start a verify.The data flow is short, but each arrow carries something specific. The server creates a challenge (one code, its expiry, its attempt counter) and sends the code out of band by SMS or email. The page gets only the challenge's id and its public numbers. The code comes back in through the field and goes up exactly once per attempt.
Client state
All of it is screen-local state, the kind the form-state lesson calls ephemeral: nothing goes in a global store, the URL, or storage. The challenge id is safe to keep in memory or in the server session. The code itself lives only in the input's value.
type Challenge = {
id: string; // opaque, from the server
channel: "sms" | "email" | "totp";
maskedDestination: string; // "+1 ••• ••• 4821"
length: number; // 6
expiresAt: string; // ISO time, about 10 minutes out
attemptsLeft: number; // 5
resendAvailableAt: string | null;
sendsLeft: number;
};
type VerifyState =
| { status: "entering"; lastSent: string | null; error?: OtpError }
| { status: "verifying"; code: string }
| { status: "expired" | "locked" }
| { status: "rateLimited"; retryAt: number }
| { status: "success" };
Server API
POST /api/otp/challenges/ch_9f2/verify
Content-Type: application/json
{ "code": "482913" }
200 { "status": "verified", "next": "/account" }
400 { "error": "INVALID_CODE", "attemptsLeft": 3 }
410 { "error": "CODE_EXPIRED" }
403 { "error": "TOO_MANY_ATTEMPTS" }
429 { "error": "RATE_LIMITED", "retryAfter": 900 }
POST /api/otp/challenges/ch_9f2/resend
200 { "resendAvailableAt": "2026-10-21T04:32:10Z", "sendsLeft": 3,
"expiresAt": "2026-10-21T04:41:40Z" }
Component API
The component-api-design lesson's rules apply: controlled and uncontrolled modes, events named for what happened, and a render slot instead of dozens of style props.
type OtpInputProps = {
length?: number; // default 6
value?: string; // controlled
defaultValue?: string; // uncontrolled
onChange?: (value: string) => void;
onComplete?: (code: string) => void; // once per complete value
allowed?: RegExp; // default /[0-9]/
inputMode?: "numeric" | "text";
autoSubmit?: boolean; // default true
disabled?: boolean;
readOnly?: boolean; // set while verifying
error?: boolean; // drives aria-invalid and slot styles
name?: string; // one FormData entry
"aria-describedby"?: string;
renderSlot?: (slot: { char: string | null; index: number;
isActive: boolean; hasCaret: boolean; isError: boolean }) => ReactNode;
};
Check your understanding · Exercise 8
Which responsibilities belong inside the design system's OtpInput (select all that apply)? Select all that apply.
Accessibility, security, and drawing the boxes
One field, one name, spoken errors
With one real input, the screen reader story is short, which is the point. The screen-readers lesson covers how names and descriptions are computed; here is what this field needs.
- One label that says what and how long: "Verification code, 6 digits". VoiceOver then reads "Verification code, 6 digits, edit text" and the typed digits as the user goes.
aria-describedbypointing at the hint ("Sent to +1 ••• ••• 4821") and at the error line, so both are read when the field gets focus.aria-invalid="true"while the error shows, and the error text in a polite live region so it's spoken when it appears, not only on the next focus.- The drawn boxes are
aria-hidden="true". They are decoration over the real field; exposing them would read every digit twice. - Focus after events: after a wrong code, focus stays on the field with the value selected. After a lock or expiry, focus moves to Send a new code, because the field can no longer help. The focus-management lesson explains why focus must never land on a removed or read-only dead end.
WCAG 2.2 success criterion 3.3.8, Accessible Authentication (Minimum), level AA, says a login step can't require a cognitive function test, like remembering or transcribing something, unless the user has help. Copy and paste, autofill, and password managers count as that help. The W3C's Understanding document says it directly: a service that requires manual transcription of a verification code is not compliant. So blocking paste or autofill on an OTP field, a pattern some banks still ship, fails AA.
The rule for the component is simple: never call preventDefault() on paste without inserting the sanitized text yourself. This matches the auth-flow guide, whose polish chapter applies the same rule to every sign-in field.
Auto-submit and screen readers: when the sixth digit is typed, the page starts verifying while the user may still be listening to that digit. Announce "Checking code" politely, and if it fails, say the error and how many tries are left. Don't add a time limit to typing. If the code expires, that's a server limit the user can recover from by sending a new code.
Think first
A VoiceOver user lands on the field after a wrong code. What should they hear, in order?
Something like: "Verification code, 6 digits, edit text, 482913, selected. That code isn't right. 3 tries left. Sent to +1 ••• ••• 4821." That's the label, the role, the value with its selection, then the descriptions in the order of aria-describedby.
Put the error id first in aria-describedby while it's showing, so the most urgent text comes first.
Security: what the client must not do
The real protections are on the server: codes expire, work once, are compared in constant time, and allow 5 tries. NIST SP 800-63B-4, the US government's authentication guideline, sets the outer limits for codes sent by SMS or email: at least 6 digits, valid for at most 10 minutes, and accepted only once. This guide's numbers sit inside those limits.
With a 6-digit code there are 1,000,000 possible values, so 5 guesses succeed 5 times in a million. A fresh code must not mean fresh guesses, though: NIST says sending a new code must not reset the failed-attempt count, and caps consecutive failures on one account at 100. So the server keeps a second counter per account, across codes, as the auth-flow guide's rate-limit section describes.
The client can't add security, but it can leak it:
- Never put the code in a URL (
/verify?code=482913). URLs end up in history, server logs, analytics, andRefererheaders. A magic link is a separate, longer token. - Never log it. Keep the field out of session replay tools (most support a mask or block attribute), error reports, and analytics events. Log "verify failed, attempts left 3", never the value.
- Don't cache it. Nothing in storage, nothing in a global store that a devtools extension or crash report might dump.
- Don't let the client decide.
attemptsLefton screen is a copy for display. A user who edits it in devtools gains nothing.
Drawing the boxes from one input
The real input sits over the slots with position: absolute, the same size as the row of boxes, with transparent text and a transparent caret (caret-color: transparent). Keep its opacity at 1, because iOS may not offer paste or autofill on an invisible field. Each slot reads its state from the value and selectionStart: the active slot gets a focus ring, and an empty active slot draws a blinking fake caret (static when prefers-reduced-motion is set). Use font-variant-numeric: tabular-nums so every digit has the same width.
In right-to-left layouts, like Arabic or Hebrew pages, the surrounding text flips but the code must not: 482913 should read left to right in its boxes, because it is copied from a message that shows it that way. Set dir="ltr" on the input and the slot row. Translate the label, hint, and errors, and format the countdown with the locale's number formatting.
Check your understanding · Exercise 9
Which choices are right for this field (select all that apply)? Select all that apply.
Running it in the interview
This is usually a 30 minute component question, or a 10 minute part of a bigger auth question. Interviewers use it to see whether you know the platform: do you reach for six inputs, or do you know why one is better?
Draw the wireframe first, then the layering of one input over the boxes. Those two pictures carry most of the signal. Spend the rest on the paths codes take in, the state machine, and the limits, and keep the server's code checking to one sentence: it's the auth-flow interview, not this one.
| Minutes | What to do | What to say out loud |
|---|---|---|
| 0 to 4 | Ask the scope questions: length, characters, channels, devices, attempt and resend limits. | "The code sources decide the build: SMS autofill and paste both deliver the whole code at once." |
| 4 to 12 | Draw the wireframe, then the one-input-over-boxes layering. Weigh it against six inputs. | "Six inputs break paste, autofill, undo, and screen readers. One real input keeps them all, and the boxes are drawn from its value and selection." |
| 12 to 18 | Attributes and input rules: one-time-code, inputmode, no type="number", one sanitize path, domain-bound SMS, WebOTP as an extra. | "Every source goes through sanitize, then the same completion rule." |
| 18 to 25 | The state machine, the verify call with the wrong-code path, the resend cooldown from server times. | "Only entering can start a verify, so a double submit is impossible." |
| 25 to 30 | Accessibility (one label, live errors, WCAG 3.3.8), security (no URLs, no logs), props. | "The client never enforces limits; it shows the server's numbers." |
Follow-ups to expect
- "Designers insist on six separate boxes in the DOM." They get six boxes on screen either way. Show the layering: the boxes are drawn, the input is real. If six inputs are truly required, list the fixes (paste split, focus moves, hidden joined input, per-box labels) and the bugs that remain (undo, one autofill target).
- "How do you test it?" Unit-test
sanitizewith pasted strings. Test the component with Testing Library'suser.pasteanduser.type, and assertonCompletefires once. Run axe for names and roles. Then test by hand on a real iPhone with a real SMS, because autofill can't be simulated well. - "What if the code has letters?" Pass
allowedand switchinputModeto text. Upper-case on input soab12cdandAB12CDmatch, and decide with the backend which letters are excluded (codes often skip 0, O, 1, I, and L). - "Can the user leave and come back?" The challenge id and its times are on the server; on return, the screen re-reads them. The code itself is not saved anywhere.
- "Should SMS even be used?" Point to the auth-flow guide: SMS can be intercepted through SIM swaps, so passkeys or an authenticator are stronger, and the same component serves TOTP codes.
Check your understanding · Exercise 10
You have 2 minutes left. Which points matter most to mention (select all that apply)? Select all that apply.
Summary
- Build one real input over drawn boxes. Six wired inputs break paste, autofill, undo, IME, password managers, and screen readers, one bug at a time.
- Put
autocomplete="one-time-code",inputmode="numeric", andmaxlengthon that one field. Never usetype="number"for codes. - Send SMS with a domain-bound last line like
@acme.com #482913: Safari suggests it only on your site, and Chrome's WebOTP can read it. WebOTP is an extra, not a dependency. - Send every source (typing, paste, autofill, WebOTP) through one sanitize function that keeps only allowed characters and cuts to length.
- Auto-submit once when complete, with a button too, guarded by a state machine so only the entering state can start a verify.
- On a wrong code, keep the value selected and show attempts left. On expiry or lock, clear it and lead with Send a new code.
- Resend cooldowns and limits come from the server as absolute times; the client counts down from them and announces only when resend becomes available.
- Never block paste, never put codes in URLs or logs, and keep digits left to right in RTL layouts.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- MDN: HTML autocomplete attribute The
one-time-codetoken and the rest of the list. - MDN: inputmode Which virtual keyboard each value asks for.
- Chrome for Developers: Verify phone numbers with the WebOTP API The SMS format, feature detection, and aborting.
- MDN: WebOTP API Browser support and the
OTPCredentialinterface. - WICG: Origin-bound one-time codes delivered via SMS The
@origin #codeformat shared by Safari and Chrome. - W3C: Understanding WCAG 2.2 success criterion 3.3.8 Accessible Authentication (Minimum) Why paste and autofill must stay allowed.
- input-otp on GitHub A one-input OTP component for React and its list of edge cases.
- web.dev: Sign-in form best practices Autocomplete tokens and form patterns around sign-in.
- Apple Developer: Enabling AutoFill for domain-bound SMS codes Safari's side of the
@domain #codeformat. - NIST SP 800-63B-4: Authentication and Authenticator Management Out-of-band code length, the 10 minute lifetime, and attempt limits.
Concepts behind it
- Forms at scaleHow to hold, validate, and submit form data so users see the right error at the right moment, never lose what they typed, and get the same rules on the client and the server.
- Validating and sanitizing inputChecks in the browser make forms friendly; checks on the server keep the data and the system safe, from file uploads to redirect URLs to the URLs your server fetches for users.
- Screen readers and live regionsHow screen readers turn a page into speech or braille, how every control gets its accessible name and description, and how to announce changes like search results, saves, and errors with live regions.
- Focus managementDeciding where keyboard focus goes when the page changes under the user (route changes, modals, deletes, new content) so keyboard and screen reader users never lose their place.
- Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.
- Designing component APIsHow to shape a component's props, children, slots, events, and ref so other engineers can use it for cases you didn't predict, without it growing a hundred props: composition, compound components, controlled state, and headless behavior.