DocumentationComponentsTheme CreatorGitHub
Theme CreatorGitHubIntroduction
InstallationUsageTheming
ComponentsAccordionAction SheetAlertAlertDialogArea ChartAspectRatioAvatarBadgeBannerBar ChartBottom NavBreadcrumbButtonButtonGroupCalendarCardCarouselChartChatBubbleChatBubbleNewCheckboxChipCoachMarkCodeBlockCollapsibleColor PickerComboboxCommandContainerContextMenuDate PickerDividerDrawerDropdown MenuEmptyStateFABFieldFieldsetFile UploadFlexFormGridHoverCardIconButtonLabelLine ChartLinkMenubarMeterModalNavigation MenuNumberFieldOTP InputPaginationPickerPie ChartPopoverProgressPromptInputRadar ChartRadial ChartRadioRatingScroll AreaSearchSegmentedControlSelectShortcutSidebarSkeletonSliderCircularSliderMediaTrimmerSpacerSpinnerSplit ButtonStackStatusStepperSurfaceSwitchTableTabsTextareaTime PickerToastToggleButtonToggleGroupTokenizerToolbarTooltipTop Header DesktopTop Header Mobile
Contributing
Components

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

Usage

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

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 | rtlltrText direction; mirrors the arrow keys
namestring—Name of the hidden aggregate input
formstring—Associate that input with a form by id
aria-labelstring"One-time password"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"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

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

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.

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

On this page

PlaygroundWhen to useInstallationUsageExamplesDefaultVariantsFusedPIN dotsSizesLengthsIntentsSeparatorErrorVerifyingAlphanumericResendVerification screenOn colourStatesSkeletonAPI ReferenceOtpInputOtpInputSeparatorOtpInputSkeletonKeyboard interactionsDo / Don'tAccessibilityAgent notes (AI-first)