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.
Playground
When to use
Use OTP Input when someone must enter a short code they were just given — an SMS or email verification code, a 2FA code from an authenticator, a passcode to unlock an app, or a confirmation code for a payment.
Reach for something else when:
- The value is secret and remembered, not delivered — that is a password field, which should stay a single input with a reveal toggle.
- The code is long or free-form (a licence key, a recovery phrase). Cells stop helping past about eight characters and start fighting the keyboard.
- You want a numeric quantity. Use Number Field.
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.
Verification screen
The whole step as it actually ships: contact echo, code, resend, submit.
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.
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 | ltr | Text direction; mirrors the arrow keys |
name | string | — | Name of the hidden aggregate input |
form | string | — | Associate that input with a form by id |
aria-label | string | "One-time password" | 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" | 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 |
Do / Don't
- Do let people paste. A pasted code with separators (
123-456) is filtered and distributed; blocking paste breaks autofill. - Do give a reason when a code fails, through
errorMessage. Red cells alone are invisible to a screen reader. - Don't submit the instant the last character lands without letting the user
review it. Use
onCompleteto enable a button, not to fire the request, unless the flow is genuinely one-shot. - Don't clear every cell on a wrong code by default — correcting one character is usually kinder than retyping six.
- Don't use
intent="success"on the cells to mean "verified". Verification should move the user on, not recolour the field. - Don't reach past eight cells. Long codes belong in a single text field.
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.
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))}
/>