OTP

input.otp[type="text"]

OTP — the one-time code field: four to eight characters, drawn as one cell per character.

Examples

One input, not six

View “One input, not six” as

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.

Filled cells, and the sizes

View “Filled cells, and the sizes” as
<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.

Level 2 tokens declared by otp
TokenDefault
--otp-pitchcalc(var(--otp-cell-size) + var(--otp-gap))
--otp-slackcalc((var(--otp-cell-size) - 1ch) / 2)
--otp-trackcalc(var(--otp-length) * var(--otp-pitch) - var(--otp-gap))
--otp-caret-colorvar(--color-primary)
--otp-cell-bgtransparent
--otp-cell-rule-colorvar(--color-border-strong)
--otp-cell-rule-sizevar(--border-width-strong)
--otp-cell-size2.5rem
--otp-colorvar(--color-on-surface)
--otp-font-familyvar(--font-mono)
--otp-font-sizevar(--font-size-xl)
--otp-gapvar(--space-2)
--otp-invalid-rule-colorvar(--color-danger)
--otp-length6
--otp-min-block-size3rem

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

  1. autocomplete="one-time-code". See above.
  2. inputmode="numeric" for a numeric code, so the keypad appears. Leave it off for an alphanumeric one.
  3. maxlength and --otp-length must be the same number. CSS cannot read maxlength, 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.
  4. A real <label>, and pattern if the code has a fixed shape — that is what drives :user-invalid.
  5. type="text", not type="number". A code is a numeric string: it keeps its leading zeros, it is never stepped, and type="number" would discard it wholesale the moment it failed to parse. See number-input.css.

src/css/components/otp.css · npx mostlycss add otp