Pin Input
Fixed-length codes in per-digit boxes, with paste that fills every box and SMS autofill that actually fires.
Also called OTP Input, Verification Code, 2FA Code — in this system all of them are Pin Input.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
The whole verification step
Code, resend with a cooldown, and a way back to change the destination. The number stays editable — a mistyped email otherwise means restarting the flow.
Grouping long codes
Six digits read fine as one run. Eight benefit from a separator, which chunks them the way people say them aloud.
Error and success
The whole group changes state, never one box. A wrong code is wrong as a value; marking a single digit red implies you know which one.
Boxes or one field
Boxes communicate the length before the user starts. Past about eight they wrap on a phone and a plain monospace field is better.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
Every part, every measurement, and the reason it is that number.
We sent a 6-digit code to ada@example.com.
Six boxes in one labelled group. The caret sits in the next empty box, and the count of boxes is the instruction.
- Box size40 × 44px (md)
Taller than wide, so a single character sits in a portrait slot rather than a square. 44px tall clears the touch minimum without padding.
- Gap8px, 16px at a group break
Even spacing reads as one value. The double gap at a break is what chunks eight digits into two fours.
- Type17px monospace, centred
Monospace so every digit sits identically in its box, and larger than body text because these characters are being checked against another screen.
- FocusBorder + 3px halo, one box
Only the active box is focused. The group border never changes on focus, or six boxes appear active at once.
- StatusApplied to every box
A wrong code is wrong as a whole. Marking one box red claims you know which digit was mistyped.
- Autofill anchorFirst box only
autocomplete="one-time-code" on box one. On every box, the platform fills each with the entire code.
- Group labelOne for all boxes
role="group" with a name. Six unlabelled inputs announcing "edit text, blank" is the worst outcome available here.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| --ds-surface-inset | — | Box fill |
| --ds-border-interactive | — | Idle box border |
| --ds-accent | — | Focused box border |
| --ds-accent-subtle | — | Focus halo |
| --ds-danger-border | — | Incorrect code — every box |
| --ds-success-border | — | Verified — every box |
| --ds-fg | — | The digits |
| --ds-border-strong | — | The group separator dash |
Spacing
| Token | Value | Used for |
|---|---|---|
| --space-2 | Gap between boxes |
Radius
| Token | Value | Used for |
|---|---|---|
| --radius-md | Box corners |
Typography
| Token | Value | Used for |
|---|---|---|
| font-mono | — | Digits, so each sits identically in its box |
Motion
| Token | Value | Used for |
|---|---|---|
| --duration-fast | Border and halo transition |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Label gap | Type | Min width | When to use |
|---|---|---|---|---|---|
| Small | 36px | 6px | 15px | 32px | Inside a dialog, or when eight boxes must fit a narrow column. |
| Medium | 44px | 8px | 17px | 40px | The default. Clears the touch minimum with no extra padding. |
| Large | 56px | 10px | 21px | 48px | A dedicated verification screen where the code is the only thing on it. |
| Group separator | — | 16px | — | — | Double the standard gap, with a short dash. Only worth it past six digits. |
| Max length | — | — | — | 8 boxes | Past eight, boxes wrap on a phone and a single monospace field is better. |
onPaste → digits.slice(0, length) → fill allif (code.length === length) verify(code)Not a checklist to run at the end. These are the requirements the component was built from.
Contrast
- Box borders owe 3:1 — they are the only thing showing how many digits are expected.
- The digits owe 4.5:1. They are being checked against another screen, so legibility matters more here than almost anywhere.
- The error state changes every border plus the message, so it survives greyscale.
- A masked box must still show that it contains something — an empty box and a masked one cannot look alike.
Keyboard
| 0–9 | Enters a digit and advances to the next box. |
| Backspace | Clears the current box, or clears the previous one and moves there if the current box is empty. |
| ← / → | Moves between boxes without changing anything. |
| ⌘ / Ctrl + V | Fills every box from the pasted string, ignoring separators. |
| Tab | Leaves the whole group. Boxes are not individual tab stops — Tab six times to leave a code field is punishing. |
Screen readers
- The group announces once: "Verification code, 6 digits, group". Each box then announces its position.
- Announce completion: "Code complete, verifying". Auto-submit with no announcement leaves the user unsure anything happened.
- WCAG 2.2’s Accessible Authentication criterion is why paste must work — requiring a user to transcribe a code by memory is exactly the cognitive test it prohibits.
Focus & touch
- Focus moves automatically as digits are entered, and selects the box content on focus so typing replaces rather than appends. On a failed attempt, focus returns to the first box with the code intact — ready to correct, not to retype.
- inputmode="numeric" with type="text" gives the keypad without stripping leading zeros. Boxes at 44px tall clear the target minimum without padding. Keep the whole group above the on-screen keyboard — a code field that scrolls under the keyboard while autofill offers the code above it is the worst possible arrangement.
| Attribute | Applied to | Notes |
|---|---|---|
| role="group" | The container | With aria-label: "Verification code, 6 digits". This is what makes the boxes one question. |
| aria-label | Each box | "Digit 1 of 6". Never bare — an unlabelled box announces as "edit text, blank". |
| autocomplete="one-time-code" | The first box only | Triggers platform autofill. On every box it fills each one with the entire code. |
| inputmode="numeric" | Each box | Numeric keypad on mobile. With type="text", so leading zeros survive. |
| aria-invalid | Every box | The whole code is wrong, not one digit. |
| role="status" | The result message | Announces success or failure. A silent failure leaves the user staring at an unchanged screen. |
Example usage
1import { PinInput } from '@/ui/Input'23<Field4 label="Verification code"5 description="We sent a 6-digit code to ada@example.com."6 status={error ? 'error' : 'default'}7 message={error}8>9 <PinInput10 length={6}11 value={code}12 onChange={setCode}13 // Nothing left to decide once the last digit lands.14 onComplete={verify}15 />16</Field>1718// Paste is the PRIMARY path, not an edge case. Users copy from an SMS.19function onPaste(e: React.ClipboardEvent) {20 e.preventDefault()21 const digits = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, length)22 if (!digits) return23 onChange(digits)24 refs.current[Math.min(length - 1, digits.length)]?.focus()25}2627// One press, one deletion.28function onKeyDown(e: React.KeyboardEvent, i: number) {29 if (e.key !== 'Backspace') return30 e.preventDefault()31 if (value[i]) return setAt(i, '')32 if (i > 0) {33 onChange(value.slice(0, i - 1))34 refs.current[i - 1]?.focus()35 }36}3738// On failure, keep the code and return to the first box. Usually one digit39// was mistyped, and the code may expire before they can retype all six.40async function verify(code: string) {41 const ok = await api.verify(code)42 if (!ok) {43 setError('That code is incorrect or has expired.')44 refs.current[0]?.focus()45 }46}Framework-free HTML
<div role="group" aria-label="Verification code, 6 digits">
<!-- one-time-code on the FIRST box only, or the platform fills every box
with the entire code. -->
<input
type="text"
inputmode="numeric"
autocomplete="one-time-code"
maxlength="1"
aria-label="Digit 1 of 6"
/>
<input type="text" inputmode="numeric" autocomplete="off"
maxlength="1" aria-label="Digit 2 of 6" />
<input type="text" inputmode="numeric" autocomplete="off"
maxlength="1" aria-label="Digit 3 of 6" />
…
</div>
<p role="status" aria-live="polite">Code complete, verifying</p>CSS
.ds-pin {
display: flex;
align-items: center;
gap: 8px;
}
.ds-pin input {
inline-size: 40px;
block-size: 44px; /* clears the touch minimum unpadded */
border: 1px solid var(--ds-border-interactive);
border-radius: var(--radius-md);
background: var(--ds-surface-inset);
text-align: center;
/* Every digit sits identically in its box. */
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
font-size: 17px;
}
/* Only the active box. The group border never changes, or six boxes look
focused at once. */
.ds-pin input:focus-visible {
border-color: var(--ds-accent);
box-shadow: 0 0 0 3px var(--ds-accent-subtle);
outline: none;
}
/* The whole code is wrong, not one digit. */
.ds-pin[data-status='error'] input { border-color: var(--ds-danger-border); }
.ds-pin[data-status='success'] input { border-color: var(--ds-success-border); }
/* Double gap at a chunk break, past six digits. */
.ds-pin__separator {
inline-size: 12px;
block-size: 1px;
margin-inline: 4px;
background: var(--ds-border-strong);
}
/* Native spinners have no business here. */
.ds-pin input::-webkit-inner-spin-button { appearance: none; }Component API
PinInput
| Prop | Type | Default | Description |
|---|---|---|---|
| length | number | 6 | Number of boxes. Past eight, use a Text Field instead. |
| value* | string | — | The whole code as one string. Never an array — paste and autofill both arrive as one value. |
| onChange* | (v: string) => void | — | Fires with the complete current value on every change. |
| onComplete | (v: string) => void | — | Fires once the last box is filled. Submit here rather than adding a button. |
| mask | boolean | false | For codes shown on a shared screen. A masked box must still look different from an empty one. |
| groupAfter | number | — | Inserts a separator after this many boxes. Only worth it past six digits. |
| status | 'default' | 'error' | 'success' | 'default' | Applied to every box — the code is a single value. |
Professional tips
- Keep the destination editable on this screen. A mistyped email otherwise means restarting the entire flow, and that is where verification funnels lose people.
- Put a cooldown on Resend and show the countdown. Without it users press it repeatedly and receive three codes, only the last of which works.
- Say how long the code lasts. "Expires in 10 minutes" prevents the confused retry after someone comes back from another tab.
- Accept the code with spaces or dashes when pasted. Some clients format the code in the message body.
- Focus the first box on mount, so a user who is already holding the code can start typing immediately.
Performance
- Keep one string in state, not an array of characters. Paste and platform autofill both arrive as one value, and an array turns that into a merge problem.
- Debounce nothing here. The code is six characters and every keystroke should be instant.
- Do not re-render the whole form on each digit. Keep the code local and lift it on completion.
Common mistakes
- Paste filling only the focused box, breaking the primary path silently.
- autocomplete="one-time-code" on every box, so autofill puts the whole code in each one.
- Unlabelled boxes announcing "edit text, blank" six times.
- Backspace needing two presses to delete the previous digit.
- Clearing the whole code on a failed attempt, forcing a full retype.
- Marking one box as the wrong digit, when the server only rejected the whole code.
- type="number", which strips leading zeros and adds a spinner.
- Boxes as individual tab stops, so leaving the field takes six Tab presses.
Real-world recommendations
- Verification is one of the highest-drop-off steps in any sign-up. Autofill, paste and an editable destination are worth more than any visual refinement here.
- Six digits is the de facto standard because it is what most authenticator apps emit. Four feels insecure to users even when it is not; eight is where boxes stop fitting comfortably.
- Codes arriving by SMS are frequently read aloud from another device. Keep the digits large and monospaced — this is one of the few places where type size is a functional requirement.
- If a meaningful share of users fail on the first attempt, check whether the code is being wrapped or truncated in the message before blaming the input.