DocumentationComponentsTheme Creator
Theme CreatorGitHubIntroduction
InstallationUsageThemingMotion
Components
CheckboxRadioSwitchTextareaNumberFieldOTP InputSliderCircularSliderRatingSelectPickerComboboxSearchSearchSuggestTokenizerSegmentedControl
AI agentsContributing
  1. Components
  2. OTP Input

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/ui

When 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 onComplete to 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

Terminal
pnpm add @tessinaui/ui

Usage

example.tsx
import { OtpInput, OtpInputSeparator } from "@tessinaui/ui";
example.tsx
<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:

example.tsx
<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

PropTypeDefaultDescription
lengthnumber6Number of character cells
valuestring—Controlled value
defaultValuestring""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
variantoutline | filled | underline | fused | ghost | dotsoutlineVisual shell
sizexs | sm | md | lg | xlmdCell density
intentnone | error | warning | success | infononeColour intent
roundednone | sm | md | lg | fullmdCorner treatment. full caps at the panel radius — a square cell at a pill radius is a circle, which belongs to dots
tonedefault | on-colorinheritedWhether the control sits on a coloured plate
typenumeric | alphanumeric | alphabeticnumericAllowed character set
maskbooleanfalseRender bullets instead of characters
normalizeValue(value: string) => string—Transform every accepted value. Must be idempotent
errorMessageReactNode—Announced politely and tied to the cells. Also marks the control invalid
verifyingbooleanfalseThe code is being checked: blocks input, sets aria-busy
separatorReactNode—Rendered between groups
separatorAfternumber | number[]—0-based cell index after which to insert the separator
placeholderstring—Character shown in empty cells
disabledbooleanfalse
readOnlybooleanfalse
autoFocusbooleanfalseFocus the first empty cell on mount
dirltr | rtl—Text direction; mirrors the arrow keys. Unset, it inherits the page's direction
stringsPartial<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}
namestring—Name of the hidden aggregate input
formstring—Associate that input with a form by id
aria-labelstring"One-time password", Arabic under dir="rtl"Accessible name for the group

Every other div attribute is forwarded to the root.

OtpInputSeparator

PropTypeDefaultDescription
childrenReactNode"—"Separator content

Decorative: it is aria-hidden and claims no role.

OtpInputSkeleton

PropTypeDefaultDescription
lengthnumber6Number of placeholder cells
sizeOtpInputSizemdMirrors the control
roundedOtpInputRoundedmdMirrors the control
variantOtpInputVariantoutlineMirrors 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
labelstring"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.

KeyAction
CharacterEnter it and advance
BackspaceClear the cell, or step back and clear
DeleteClear the cell without moving
← / →Move between cells (mirrored under dir="rtl")
Home / EndFirst / last cell
Ctrl/Cmd + BackspaceClear the whole code
PasteDistributed 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 bare intent="error" — sets aria-invalid on 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-8 on a 375px screen leaves too little, which is why the examples here drop to p-4 below sm.
  • 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 as aria-describedby; errorMessage renders 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:

SelectorMeaning
[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.

example.tsx
// Copy-paste starting point
<OtpInput
  length={6}
  name="code"
  intent={error ? "error" : "none"}
  errorMessage={error}
  verifying={isPending}
  onComplete={(code) => startTransition(() => verify(code))}
/>
NumberFieldSlider

On this page

When to useWhen not to useInstallationUsageExamplesDefaultVariantsFusedPIN dotsSizesLengthsIntentsSeparatorErrorVerifyingAlphanumericResendVerification screenOn colourStatesSkeletonRTLAPI ReferenceOtpInputOtpInputSeparatorOtpInputSkeletonKeyboard interactionsAccessibilityAgent notes (AI-first)