cf.completefrontend
My progress
SYSTEM DESIGN · #10

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.

QuestionAnswer we assumeWhat 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 456 or 123-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.

Wireframe of the verify screen. A page titled Check your phone says the code was sent to the phone ending 4821. Under the label Verification code, 6 digits, six boxes hold 4, 8, 2 and 9; the fifth box is active and the sixth is empty. A hint or error line sits under the boxes, then a full-width Verify button, then the text Resend code in 0:24. Notes say the boxes are one real input, the active box follows the caret, and the countdown comes from server time.acme.com/verifyCheck your phoneSent to the phone ending 4821Verification code, 6 digits4829hint or error lineVerifyResend code in 0:24one real inputover the boxesactive boxfollows the caretcountdown fromserver time
Six boxes on screen, one field underneath. The hint line doubles as the error line, so errors appear where the eye already is, and the resend countdown sits below the main action.

It looks like a beginner exercise. Four things make it harder than it looks, and every later chapter comes back to one of them.

  1. 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 paste event, or one input event that replaces the whole value. A design that only handles the first way breaks the fastest ways.
  2. 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.
  3. 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.
  4. 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?

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 482913 into box 1. With maxlength="1", the browser keeps 4 and drops the rest. You must catch the paste event, 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 compositionend rather 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?

Six inputs wired together

One input per digit with maxlength="1".

input and keydown handlers move focus forward and back.

A paste handler splits the clipboard and fills the boxes.

Pros

  • Each box is a real element, so styling is plain CSS.
  • Easy to reason about for the first demo.

Cons

  • Paste, autofill, undo, select-all, IME, and password managers each need custom code, and some (undo) can't be fixed well.
  • Six tab stops and six names for a screen reader.
  • The value lives in six places, so the form gets six entries unless you add a hidden field.

One plain input styled with letter spacing

A single input with maxlength="6", a monospace font, and wide letter-spacing.

A background image or gradient draws six cells behind the characters.

Pros

  • Everything the browser offers works: paste, autofill, undo, selection, one label.
  • No JavaScript needed for editing.

Cons

  • The digits drift out of their cells when the font, zoom level, or browser rounding changes.
  • No per-box states: you can't highlight the active cell or show a box-level caret.
  • The last character's trailing letter spacing can cause scrolling inside the field.

One real input over drawn boxes

A single real input holds the value and keeps focus. Its text and caret are made transparent, and it is stretched over the boxes.

The boxes are plain div elements drawn from the value and the selection: which character goes where, which box is active.

Clicks land on the input, so the browser places the caret; the component reads selectionStart to know the active box.

Pros

  • Keeps everything the single field gets for free, including one label and one form value.
  • Full control over the look: active box, fake caret, error colors, a gap after 3 digits.
  • This is how the popular input-otp React library (used by shadcn/ui) works.

Cons

  • Syncing the drawn caret with the real selection takes care, especially for arrow keys and deletes.
  • Some details are browser-specific: iOS may refuse to paste into an input with opacity: 0, so you hide it with transparent text instead.
  • More code than the plain input, though it lives once in the design system.

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.

Exploded view of the one-input design. On top, a dashed box is the real input, holding the value 4829 with the caret at position 4; its text and caret are transparent, but its opacity stays 1. An arrow labelled value and selectionStart points down to six drawn slots holding 4, 8, 2, 9 and two empty boxes, with the fifth box marked active. Notes say the slots are aria-hidden and every click and key lands on the input. At the bottom, a screen reader hears one field: Verification code, 6 digits, edit text, 4829.real input: transparent text and caretvalue = "4829", caret at 4value, selectionStart4829drawn slots, aria-hiddenclicks, keys, pasteand autofill allland on the inputactive = caretfilled = indexbelow lengthScreen reader: "Verification code, 6 digits, edit text, 4829"
The input is stacked exactly over the slots in the real page, with transparent text and caret but full opacity; here it is lifted up to show both layers. The browser handles editing on the input, and the slots only redraw from 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.

Two designs side by side. One input holds "" with the caret at box 1; the form gets "". Six inputs hold empty, empty, empty, empty, empty, empty with focus on box 1; the form gets "".One real input over boxesform value: ""Six wired inputs, maxlength 1form value: ""0 of 6 digits1 tab stop, 1 name0 of 6 digits6 tab stops, no names
Same event, two designs. Paste and the keyboard suggestion fill the one-input design in a single step, while the naive six inputs keep one digit and lose the rest unless you write custom handlers. Undo and screen reader focus show the same split.

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.

html
<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" />
The attributes every OTP field should carry. The next chapter explains inputmode, pattern, and maxlength.

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.

text
Your Acme code is 482913. It expires in 10 minutes.

@acme.com #482913
The last line is the origin after @ and the code after #. The first line stays human-readable.

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.

ts
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]);
}
Abort when the component unmounts or the user submits by hand, or a late SMS could fill a field that is already verified.
Sequence diagram of the WebOTP flow with four lifelines: User, Page, Chrome, and Server. The page posts to send a code and gets a challenge id back. The page calls navigator.credentials.get with the otp option. The server's SMS ending in at acme.com hash 482913 reaches Chrome. Chrome asks the user to allow reading the code, the user taps Allow, and Chrome resolves the promise with code 482913. The page sanitizes it, sets the value, posts verify, and the server answers 200 verified.UserPageChromeServerPOST send codechallengeIdcredentials.get(otp)SMS #482913Allow to read code?Allow{ code }POST verify200 verified
Chrome only hands the code to the page whose origin is on the SMS's last line, and only after the user taps Allow. The page treats the result like a paste: sanitize, set the value, then the normal completion rule submits it.

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.

ts
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"
Filtering, not rejecting. A seventh digit is cut, not an error, because users paste whole messages.

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?

Only on a Verify button

The field never submits by itself. Enter or the button submits the form.

Pros

  • No surprises: the user decides.
  • Screen reader users can review the code before sending.

Cons

  • One more tap after autofill, on the screen where users drop off most.

Auto-submit when complete, plus a button

When the value reaches length through any source, call onComplete(code) once.

The button stays for anyone who edits the code or whose submit failed.

Pros

  • Fastest path for autofill, paste, and WebOTP.
  • Still works for users who prefer the button.

Cons

  • A typo in the sixth digit is sent before the user notices, spending an attempt.
  • A screen reader user hears the page change while still checking the digits.
  • Needs a guard so the same code isn't sent twice.

Auto-submit only for one-shot fills

Submit automatically when the whole code arrived in one event (paste, autofill, WebOTP).

Typed codes wait for Enter or the button.

Pros

  • Removes the typo risk of digit-by-digit entry.
  • Keeps the one-tap autofill path.

Cons

  • Two behaviors to explain and test.
  • Users who type expect it to submit like the autofill did.

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.

State machine of the verify screen. Entering moves to verifying when the code is complete and is a new value. Verifying is read-only and ignores Enter. From verifying, 200 leads to success, 410 to expired, 403 with no tries left to locked, and 429 to rate limited. A 400 wrong code loops back to entering with the error and tries left. A note says that from expired or locked, sending a new code returns to entering.enteringeditingcompletenew valueverifyingreadOnlysuccess200expired410locked403, 0 leftrateLimited429400: wrong code, tries leftexpired or locked: Send a new codereturns to entering with a fresh challenge
The double-submit guard is the shape of the machine: a second 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 (not disabled, 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 in entering, 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.
Sequence diagram of verifying with a failure path, with four lifelines: User, OtpInput, VerifyScreen, and Server. The user types the sixth digit. OtpInput calls onComplete with the code. VerifyScreen moves to verifying and posts verify. The server replies 400 with 3 tries left. VerifyScreen sets the error and selects all in OtpInput, which announces That code isn't right, 3 tries left. The user types a fixed code, VerifyScreen posts verify again, and the server replies 200 verified.UserOtpInputVerifyScreenServertypes 6th digitonComplete(code)POST verify400, 3 tries lefterror, select all3 tries lefttypes fixed codePOST verify200 verified
The failure path keeps the value and selects it, so the next digits typed replace the whole code. The error text goes through a live region, so it is heard without moving focus.

Error messages that say what to do next

Server saysMessageField
INVALID_CODE with attemptsLeft: 3That code isn't right. 3 tries left.Keep the value, select all, focus.
INVALID_CODE with attemptsLeft: 1That code isn't right. 1 try left before you need a new code.Same, with stronger wording.
CODE_EXPIREDThis code has expired. Send a new one.Clear, read-only, focus the Send button.
TOO_MANY_ATTEMPTSToo many wrong codes. Send a new code to try again.Clear, read-only, focus the Send button.
RATE_LIMITED with retryAfter: 900Too many tries. You can try again in 15 minutes.Read-only, no resend until the time passes.
Network error or 5xxWe 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.

Verify lab at 0:00. State entering. The field holds "". 5 tries left of 5. Resend in 30 seconds. 1 of 5 sends used. The server got 0 verify requests. The phone's latest SMS says the code is 246810.state: enteringtries leftResend code in 0:30code expires in 10:00, sends 1 of 5clock 0:00, verify requests: 0SMS on the phone:Your code is 246810.@acme.com #246810
The clock is simulated so you can skip ahead. Turn off the guard and try Autofill and press Enter at once: the second request reaches the server after the first used the code, and a success turns into an error.

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?

A client timer

Start setTimeout for 30 seconds after each send, then enable the button.

Pros

  • Simplest code, no API changes.

Cons

  • A reload resets it.
  • It doesn't know the server's hourly limit, so the button can be on while the server refuses.
  • Background tabs throttle timers, so the display freezes and jumps.

Server times, client display

Each send response includes resendAvailableAt and sendsLeft.

The client counts down to that time, corrected by the clock offset, and re-reads the challenge on load.

Pros

  • One source of truth for limits and the display.
  • Survives reloads and new tabs.
  • The server can change the policy without a frontend release.

Cons

  • Needs the fields in the API and a clock offset.

No cooldown, the server refuses

Resend is always on. Early taps get 429 with an error message.

Pros

  • No timer code at all.

Cons

  • Users tap, fail, and don't know how long to wait.
  • Every refused tap is a wasted request and a frustrating error.

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.

Timeline of resends over 4 minutes. Send 1 at 0 seconds starts a 30 second cooldown, send 2 at 30 seconds starts 60 seconds, send 3 at 90 seconds starts 120 seconds, and send 4 is at 210 seconds. Below, the valid code changes with each send: code A, then B, then C, then D, because each new send kills the last code. Notes say to announce only when resend becomes available, and that after 5 sends in an hour the server answers 429 with retryAfter.cooldown after each send, set by the server30 s60 s120 ssend 1send 2send 3send 401 min2 min3 min4 minvalid code: each send kills the last oneABCDannounce once: you canrequest a new code nowafter 5 sends an hour:429 with retryAfter
The waits grow so a user mashing Resend slows down, while the first retry stays quick. The server sends resendAvailableAt with each code, so a reload shows the same remaining time.
ts
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));
}
The interval only triggers re-renders; the value is always recomputed from the target, so a throttled tab catches up on its next tick.

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.

PartResponsibilityWhy the boundary is here
OtpInputOne 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.
VerifyScreenOwns the state machine, error messages, attempts left, and focus after errors.Business rules differ per flow; this is where they live.
ResendControlCountdown from resendAvailableAt, button state, announcements.Same control under SMS and email; it only needs the challenge.
useWebOtpOptional WebOTP request with abort.Chromium-only, so it's a separate enhancement that can be left out.
otpApi clientverify, resend, parsing error codes, clock offset from Date.One place that maps server errors to the state machine's events.
Component architecture. In the browser zone: useWebOtp, dashed because it is Chromium only, passes a code down to OtpInput. VerifyScreen, the state machine, passes props to OtpInput and calls verify on the otpApi client. ResendControl, a server-timed countdown, calls resend on otpApi. OtpInput draws six OtpSlot boxes. In the server zone, otpApi posts JSON to the OTP API, which sends the code through an external SMS or email provider.BrowseruseWebOtpChromium onlyVerifyScreenstate machineResendControlserver countdownOtpInputone real inputotpApiverify, resendOtpSlotcodepropsverifyresenddraws x6ServerOTP APISMS / emailPOST, JSONcode
The design system owns 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.

Data flow of a code. Top row, left to right: the OTP API checks codes and sends the code to an SMS or email provider, which delivers the message out of band to the phone's OS, which suggests the code above the keyboard. An arrow down says autofill, paste, or WebOTP into OtpInput, which sanitizes. OtpInput calls onComplete on VerifyScreen, which sends the code once to the otpApi client, which posts it to the OTP API. A note says the reply is 200 verified or 400 with attemptsLeft, 410, 403, or 429, and that the code never goes into the URL, logs, storage, or analytics.OTP APIchecks codesSMS / emailout of bandPhone OSsuggests codecodemessageotpApifetchVerifyScreenstate machineOtpInputsanitizeautofill, paste,or WebOTPonCompletecode, oncePOST { code }back: 200 verified, or 400 with attemptsLeft, 410, 403, 429never in the URL, logs, storage, or analytics
The code leaves the server by a side channel and comes back through exactly one field and one request. Everything the page keeps is the challenge: its id, expiry, tries left, and resend time.

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.

ts
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" };
The code is never part of this state. It lives in the input and travels once, in the verify request body.

Server API

http
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" }
The code goes in a POST body, never in the URL. Errors carry the numbers the UI needs.

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.

tsx
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;
};
The label stays outside the component, linked with id, so each screen can word it for its channel.

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-describedby pointing 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?

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, and Referer headers. 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. attemptsLeft on 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.

MinutesWhat to doWhat to say out loud
0 to 4Ask 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 12Draw 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 18Attributes 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 25The 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 30Accessibility (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 sanitize with pasted strings. Test the component with Testing Library's user.paste and user.type, and assert onComplete fires 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 allowed and switch inputMode to text. Upper-case on input so ab12cd and AB12CD match, 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", and maxlength on that one field. Never use type="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.

Junior 0 of 3
Mid-level 0 of 4

References

Concepts behind it

Try next