OTP Input
One-time code entry with individual character cells. Six visual variants, five sizes, five intents, masking, separator groups, numeric/alphanumeric/alphabetic input, an announced error state and full RTL/LTR support.
pnpm add @tessinaui/uiWhen to use
- A short code the person was just given. An SMS or email verification code, a two-factor code from an authenticator, an app passcode or a payment confirmation code.
- Pasted and autofilled codes. Let people paste. A pasted code with separators (
123-456) is filtered and spread across the cells, and blocking paste breaks autofill. - A reason when a code fails. Give it through
errorMessage: red cells alone are invisible to a screen reader. - Keeping the entered code on a wrong attempt. Correcting one character is usually kinder than retyping all of them, so clear the cells only when the flow needs it.
- A review step before submitting. Use
onCompleteto enable a button rather than to fire the request, unless the flow is genuinely one-shot. - Short codes. Cells stop helping as a code grows long (see Accessibility for the phone width budget).
When not to use
- For a secret the person remembers. A password is not delivered, so keep it a single input with a reveal toggle.
- For a long or free-form code, such as a licence key or a recovery phrase. Past about eight characters cells fight the keyboard; use a single text Field.
- For a numeric quantity. Use NumberField.
- To show that a code is verified. Do not recolour the cells with
intent="success"; verification should move the user on.
Installation
pnpm add @tessinaui/uiUsage
import { OtpInput, OtpInputSeparator } from "@tessinaui/ui";<OtpInput length={6} onComplete={(code) => verify(code)} />The component keeps a hidden aggregate input, so it works inside a plain form
with no wiring — name posts the whole code as one value:
<form action={verifyAction}>
<OtpInput length={6} name="code" />
<button type="submit">Verify</button>
</form>Examples
Default
A 6-digit numeric code. On a phone the cells shrink to hold one row rather than wrapping.
Variants
Six shells: outline, filled, underline, fused, ghost and dots.
Fused
Cells share their borders and sit flush, so the group reads as a single control. This is a desktop-web idiom.
PIN dots
Circular slots that fill as you type, with no box. The app-unlock idiom — always
pair it with mask.
Sizes
Five cell sizes from xs to xl.
Lengths
Four, five, six or eight characters.
Intents
Colour intents for validation feedback. Note that intent alone is a visual
signal — see Error for the announced version.
Separator
Split the cells into groups.
Error
errorMessage renders into a polite live region that ships from first render,
so assistive technology hears the failure instead of only seeing red cells.
Whether the wrong digits survive is your call — apps in the wild are split
roughly evenly, so both are shown.
Verifying
verifying blocks input and sets aria-busy while the server checks the code.
Alphanumeric
Letters and digits, upper-cased through normalizeValue.
Resend
A countdown-gated resend. Deliberately a composition, not a prop — a timer does not belong inside a text control. Keep the countdown out of a live region, or every tick is announced; announce once, when the resend becomes available.
Verification screen
The whole step as it actually ships: contact echo, code, resend, submit. On a
real verification screen, pass autoFocus — the code is the only thing on the
step. It is off here so this page does not jump to the example on load.
On colour
tone is inherited from a wrapping Surface.
States
Masked, disabled and read-only.
Skeleton
Pass the same variant as the control it stands in for, or the placeholder
promises a box that never arrives.
RTL
Unset, dir is inherited from the page, so on an RTL page the first digit sits in the rightmost cell. Codes, card and phone numbers conventionally read left to right in Arabic interfaces: pass dir="ltr" to keep the first digit in the leftmost cell while the surrounding copy mirrors. The arrow keys follow the visual order either way.
API Reference
OtpInput
| Prop | Type | Default | Description |
|---|---|---|---|
length | number | 6 | Number of character cells |
value | string | — | Controlled value |
defaultValue | string | "" | Uncontrolled initial value |
onValueChange | (value: string) => void | — | Called on every change |
onComplete | (value: string) => void | — | Called when the code becomes complete, and again when a complete code changes. Never on re-render |
variant | outline | filled | underline | fused | ghost | dots | outline | Visual shell |
size | xs | sm | md | lg | xl | md | Cell density |
intent | none | error | warning | success | info | none | Colour intent |
rounded | none | sm | md | lg | full | md | Corner treatment. full caps at the panel radius — a square cell at a pill radius is a circle, which belongs to dots |
tone | default | on-color | inherited | Whether the control sits on a coloured plate |
type | numeric | alphanumeric | alphabetic | numeric | Allowed character set |
mask | boolean | false | Render bullets instead of characters |
normalizeValue | (value: string) => string | — | Transform every accepted value. Must be idempotent |
errorMessage | ReactNode | — | Announced politely and tied to the cells. Also marks the control invalid |
verifying | boolean | false | The code is being checked: blocks input, sets aria-busy |
separator | ReactNode | — | Rendered between groups |
separatorAfter | number | number[] | — | 0-based cell index after which to insert the separator |
placeholder | string | — | Character shown in empty cells |
disabled | boolean | false | |
readOnly | boolean | false | |
autoFocus | boolean | false | Focus the first empty cell on mount |
dir | ltr | rtl | — | Text direction; mirrors the arrow keys. Unset, it inherits the page's direction |
strings | Partial<OtpInputStrings> | English, Arabic under dir="rtl" | Override the group's name (label) and each cell's (digit, "Digit {n} of {total}"). aria-label wins over label. A code pinned to dir="ltr" on an Arabic page picks English names; pass strings={OTP_INPUT_STRINGS.ar} |
name | string | — | Name of the hidden aggregate input |
form | string | — | Associate that input with a form by id |
aria-label | string | "One-time password", Arabic under dir="rtl" | Accessible name for the group |
Every other div attribute is forwarded to the root.
OtpInputSeparator
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | "—" | Separator content |
Decorative: it is aria-hidden and claims no role.
OtpInputSkeleton
| Prop | Type | Default | Description |
|---|---|---|---|
length | number | 6 | Number of placeholder cells |
size | OtpInputSize | md | Mirrors the control |
rounded | OtpInputRounded | md | Mirrors the control |
variant | OtpInputVariant | outline | Mirrors the control's shape — circles for dots, a rule for underline, a flush run for fused. The outer box is the same in every variant, so swapping in the real control shifts nothing |
label | string | "Loading", Arabic under dir="rtl" | Announced while the placeholder is up |
Keyboard interactions
The group is one tab stop. Tab moves past the whole code, not through it.
| Key | Action |
|---|---|
| Character | Enter it and advance |
Backspace | Clear the cell, or step back and clear |
Delete | Clear the cell without moving |
← / → | Move between cells (mirrored under dir="rtl") |
Home / End | First / last cell |
Ctrl/Cmd + Backspace | Clear the whole code |
| Paste | Distributed across the cells from the focused one |
Accessibility
- The group is
role="group"with an accessible name; each cell is labelled by position (Digit 3 of 6). errorMessage— or a bareintent="error"— setsaria-invalidon the cells and is announced through a polite live region that is present from first render. An error region inserted on demand is silent in Safari and Chrome alike, so this one never is. Polite rather than assertive: the user is mid-entry and should not be interrupted.autocomplete="one-time-code"is set on the first cell and the hidden aggregate input only. Six fields each claiming the token is what actually breaks SMS autofill — the OS delivers the whole code into one field, and it is then redistributed across the cells exactly like a paste.- One tab stop, so the control costs the same keyboard effort as a single input.
- Cells hold a 44px touch floor on phones and shrink to keep the code on one row.
The floor needs room: six cells at 44px plus gaps want about 284px of
container, which every phone gives. Eight cells want about 380px, which no
phone content column has — at 320–375px an 8-character code renders 37×44
cells. That still clears WCAG 2.5.8 Target Size (Minimum, AA, 24×24) but misses
the 44px house floor, so keep phone codes at six or fewer. Padding counts
against that budget: a plate with
p-8on a 375px screen leaves too little, which is why the examples here drop top-4belowsm. - Motion is gated behind
prefers-reduced-motion. - Contrast (
audit:contrast, both themes):intent="warning"paints a raw-amber cell border that reads 2.2:1 against the light page, under the 3:1 WCAG 1.4.11 asks — the only intent border that misses it. It is the same warning edge Field and the rest of the input family paint, held at the intent palette's accepted ceiling (§9, TES-272), so the amber must never carry a warning alone: pair it with a visible message beside the code. Write that line yourself and pass its id asaria-describedby;errorMessagerenders in error ink and marks the code invalid, which is wrong for a warning.
Not yet verified: NVDA and JAWS. Announcements were checked with VoiceOver only.
Agent notes (AI-first)
Stable hooks for automation:
| Selector | Meaning |
|---|---|
[data-slot="otp-input"] | Root. Carries data-size, data-variant, data-intent, data-rounded, data-tone |
[data-complete] | Present on the root when every cell is filled |
[data-invalid] | Present on the root when the code is invalid |
[data-verifying] | Present while a code is being checked |
[data-slot="otp-input-cell"][data-index="N"] | The Nth cell; carries data-filled and data-focused |
[data-slot="otp-input-error-region"] | The polite live region |
[data-slot="otp-input-skeleton-cell"] | A placeholder cell |
To fill a code programmatically, set the value on the first cell's input and
dispatch a paste, or drive the controlled value prop — writing into individual
cell inputs one at a time will fight the focus manager.
// Copy-paste starting point
<OtpInput
length={6}
name="code"
intent={error ? "error" : "none"}
errorMessage={error}
verifying={isPending}
onComplete={(code) => startTransition(() => verify(code))}
/>