OTP
OTP — the one-time code field: four to eight characters, drawn as one cell per character.
Examples
The six digits we sent to your phone.
<div class="field">
<label class="field-label" for="code">Confirmation code</label>
<input class="otp otp-center" id="code" name="code" type="text" inputmode="numeric"
autocomplete="one-time-code" pattern="[0-9]{6}" maxlength="6"
aria-describedby="code-help" />
<p class="field-help" id="code-help">The six digits we sent to your phone.</p>
</div>The cells are paint. The "OTP-style input" everyone ships is six separate <input>s with a script moving focus between them, and it is one of the most reliably inaccessible patterns in form design — not because of one bug but because of six.
Paste puts the whole code in the first box or is dropped outright. Autofill has nowhere to go: autocomplete="one-time-code" names one field, so the code offered above the keyboard on iOS and Android cannot be accepted. The form receives six values where the server wants one. A screen reader announces "edit, blank, 1 of 6" six times and never announces the code as a value. Backspace at the start of a box, arrow keys across boxes, select-all and undo all have to be rebuilt by hand, and they are usually rebuilt wrong — Backspace crossing backwards is the one that is missing almost everywhere. And the auto-advance itself moves focus without the user asking, which is a WCAG 3.2.2 On Input problem before any of the rest of it. Every one of those is a defect introduced purely to obtain a visual, and the visual is available from CSS: one input, a monospace face, a letter-spacing on a fixed pitch, and a repeating gradient painting a cell under each character.
<div class="field">
<label class="field-label" for="code-sm">Small</label>
<input class="otp otp-sm otp-center" id="code-sm" name="code-sm" type="text"
inputmode="numeric" autocomplete="one-time-code" maxlength="6" value="4821" />
</div>
<div class="field">
<label class="field-label" for="code-filled">Filled cells</label>
<input class="otp otp-filled otp-center" id="code-filled" name="code-filled" type="text"
inputmode="numeric" autocomplete="one-time-code" maxlength="6" value="482190" />
</div>
<div class="field">
<label class="field-label" for="code-lg">Large, four characters</label>
<input class="otp otp-lg otp-center" id="code-lg" name="code-lg" type="text"
inputmode="numeric" autocomplete="one-time-code" maxlength="4"
style="--otp-length: 4" value="4821" />
</div>The number of cells is --otp-length and it must agree with maxlength; nothing can derive one from the other, because CSS cannot read an attribute into a number.
.otp-center is the one piece of placement this component ships: the field has a fixed content width and is almost always alone on a confirmation screen, so left-aligning it in a full-width form leaves a short row of cells with half a page beside them.
Tokens 15
Level 2, declared on .otp itself. Set any of them on that selector to restyle this component without touching the skin.
| Token | Default |
|---|---|
| --otp-pitch | calc(var(--otp-cell-size) + var(--otp-gap)) |
| --otp-slack | calc((var(--otp-cell-size) - 1ch) / 2) |
| --otp-track | calc(var(--otp-length) * var(--otp-pitch) - var(--otp-gap)) |
| --otp-caret-color | var(--color-primary) |
| --otp-cell-bg | transparent |
| --otp-cell-rule-color | var(--color-border-strong) |
| --otp-cell-rule-size | var(--border-width-strong) |
| --otp-cell-size | 2.5rem |
| --otp-color | var(--color-on-surface) |
| --otp-font-family | var(--font-mono) |
| --otp-font-size | var(--font-size-xl) |
| --otp-gap | var(--space-2) |
| --otp-invalid-rule-color | var(--color-danger) |
| --otp-length | 6 |
| --otp-min-block-size | 3rem |
Variants and states
Variants
.otp-filled.otp-lg.otp-sm
Inside it
.field.otp-center.field-error.field-help
State it reads
Read from the platform, never mirrored into a class that could disagree with it.
:disabled:focus:user-invalid[readonly]data-legacy
Before you ship it
What you have to do 5 requirements
autocomplete="one-time-code". See above.inputmode="numeric"for a numeric code, so the keypad appears. Leave it off for an alphanumeric one.maxlengthand--otp-lengthmust be the same number. CSS cannot readmaxlength, so nothing can check this for you; get it wrong and the field accepts a character it has no cell for, or paints a cell that can never be filled.- A real
<label>, andpatternif the code has a fixed shape — that is what drives:user-invalid. type="text", nottype="number". A code is a numeric string: it keeps its leading zeros, it is never stepped, andtype="number"would discard it wholesale the moment it failed to parse. See number-input.css.
src/css/components/otp.css · npx mostlycss add otp