OTP Input

How the OTP Input works with a keyboard and assistive tech, and where it stands against WCAG 2.2 AA.

FreeReact and Vue
AAWCAG 2.2 target
6/9contrast pairs passing
6keyboard interactions

The OTP Input is a group of single-digit native inputs. Each cell is named for its place, typing moves on, and pasting or autofill fills the whole code. autocomplete="one-time-code" lets the device offer the code, so nobody has to copy it by hand.

Keyboard#

0–9

Writes the digit in the focused cell, replacing any digit there, and moves to the next cell.

Backspace

Clears the focused cell. In an empty cell, clears the previous one and moves back.

←

Moves to the previous cell without changing anything. → moves to the next.

Home

Moves to the first cell. End moves to the last.

Ctrl+V

Pastes. A whole code fills every cell; a fragment fills from the focused cell. ⌘ V on a Mac.

Tab

Moves through the cells in order, then out of the group.

Semantics#

role="group"

Named One-time code by default. Pass a name that says what the code is for: Verification code.

Always
aria-label="Digit 1 of 6"

Every cell says where it is in the code.

Each cell
autocomplete="one-time-code"

Lets iOS, Android and password managers offer the code from a message. The other cells are off, so the offer appears once.

First cell
inputmode="numeric"

Shows the number pad on touch devices, with pattern="[0-9]*" for older iOS.

Always
aria-invalid

Set on every cell, so whichever cell has focus is announced as invalid.

error
aria-describedby

Links the group to the hint below: where the code was sent, or why it failed.

Hint shown
readonly / disabled

Read Only keeps the code focusable and readable; Disabled removes it from the tab order.

Read Only, Disabled

Colour contrast#

Every variant and state has to meet WCAG 2.2 Level AA. Measure the colours as they land on screen: a translucent hover or press wash over whatever sits behind it.

4.5:1

Digits · Default, Focused, Error

Bold 14px digits in #2a2a2a on white. In the error state they turn #ec0d33, which is 4.49:1, a hair under 4.5:1.

WCAG 1.4.3 Contrast (Minimum) ↗
3:1

Cell edge · Default

An empty cell is shown only by its 1px #e1e1e1 stroke, 1.31:1 on white. The focused (#070707) and error (#ec0d33) strokes pass.

WCAG 1.4.11 Non-text Contrast ↗
Not colour alone

Error · Error

Red cells are paired with a hint that says what went wrong, and aria-invalid on the cells.

WCAG 1.4.1 Use of Color ↗WCAG 3.3.1 Error Identification ↗
3:1

Focus indicator · Focus visible

A 2px ring in #1a6ef8 off a white gap, on keyboard focus only. It measures 4.54:1 on white, so it clears 3:1 on every light surface the library uses.

WCAG 1.4.11 Non-text Contrast ↗WCAG 2.4.13 Focus Appearance ↗
Not required

Disabled · Disabled

Inactive controls are exempt. The disabled text and glyphs (#cbcbcb) are deliberately faint.

WCAG 1.4.3 Contrast (Minimum) ↗

Measured contrast#

Each part against the white cell or page, per state, from the ds.css tokens; text is held to 4.5:1 and the cell edge, a graphic, to 3:1. 3 of 9 pairs fall below their minimum (4.5:1 for text, 3:1 for graphics) and are marked in amber.

Part · elementDefaultFocusedError
Cell · digit14.35passes14.35passes4.49below the minimum
Cell · edge1.31below the minimum20.14passes4.49passes
Hint · text5.17passes5.17passes4.49below the minimum

Motion#

  • The cell's stroke changes over 150ms and is turned off under prefers-reduced-motion.
  • The caret is the browser's own and blinks as the system sets it.

Checklist#

Keep autocomplete="one-time-code" and paste working. Together they mean the code never has to be remembered or retyped (WCAG 3.3.8).

Don't submit on the last digit without warning; if you do, say so, and leave a button for people who check before sending.

Say where the code was sent and how long it lasts in the hint, and offer a way to get a new one.

Error text in #ec0d33 measures 4.49:1 on white, just under 4.5:1. A slightly darker red would clear it.

The empty cell's #e1e1e1 edge is 1.31:1, below the 3:1 non-text minimum.

Cells are 52px, well over the 24px minimum, but shrink on narrow screens; keep them at 24px or more.