OTP Field
The field someone types a six-digit code into after asking to sign in. It looks
like several inputs and behaves like one: a single value, a single tab stop, and
a single accessible name. Typing advances, Backspace retreats, and a pasted
482913 fills every slot at once instead of dropping five characters.
We sent it to the number ending 4417.
length is required and is the only thing the field needs — leave the children
out and the root renders that many slots for you. Pass children when the row is
not uniform, which in practice means one thing: a separator between groups.
Import
import { OTPField } from "@forte-ui/react";Examples
Sizes
size sets the slots' square side and the character's font size together, and
the side comes from the same --forte-control-* tokens Input and
Select.Trigger read — so a code field and the rest of a form stay on one
scale at every data-forte-density setting.
Variants
outline reads as a form control, soft as a filled field, underline as a
row of rules.
Grouped
OTPField.Separator is the dash in 482 – 913. It renders the library's
Separator with decorative on by default, because
the slots either side of it are one value and one control — announcing a
boundary in the middle of a six-digit code describes the layout, not the field.
length counts inputs, not children, so a separator can go anywhere between
them without changing it.
Masking and character set
validationType decides which characters survive being typed or pasted:
numeric (the default), alpha, alphanumeric, or none. Anything it drops
is reported through onValueInvalid rather than silently ignored, which is
what lets a code copied as 482-913 still land correctly.
It also picks the virtual keyboard — numeric asks for the digit pad, which is
most of why a phone is bearable to type a code on. Override inputMode if you
need a different one.
Letters and digits, from your backup sheet.
Masking is for a secret that gets reused — a PIN, not a code from an SMS.
States
Disabled goes on the Field.Root rather than on the field, so the label and the
description dim with the slots instead of sitting at full contrast beside a
greyed-out row. Read-only keeps full contrast, a panel-coloured background and
the text cursor.
invalid is the interesting one: a rejected code is complete and wrong, so
the danger boundary has to beat the completion boundary. That is document order
in the stylesheet, not specificity — the two rules tie.
Auto-submit
autoSubmit submits the owning form the moment the last slot fills. It suits
this control specifically: the code is the whole form, so a Verify button is a
step whose only content is "yes, the six digits I just typed".
onValueComplete runs immediately before the submit, so there is still a place
to stop it.
Controlled
The value is one string, not one per slot: value, defaultValue,
onValueChange, onValueComplete and onValueInvalid all speak in "482913".
onValueComplete is not onValueChange with a length check — it also fires
when a complete value is pasted over an identical one, where the value never
changed and onValueChange stays quiet.
(empty) · 0/6
Accessibility
| Key | Behaviour |
|---|---|
| Tab | Moves into the field, and out of it. The whole field is one tab stop: exactly one slot is in the tab order at a time, and it is the one the user left off at. |
| ← then → | Move between slots. Direction follows the writing direction. |
| Backspace | Clears the current slot, or moves back and clears the previous one when the current slot is already empty. |
| Delete | Clears the current slot without moving. |
| Home then End | Jump to the first or last slot. |
| Ctrl then V | Pastes from the slot the caret is in, filling forwards. Characters the validation type rejects are dropped rather than blocking the paste. |
The root renders role="group" and takes its name from the Field.Label above
it. The slots themselves are not named individually, and aria-label on the
first one is ignored on purpose — Base UI warns about it — because the name
belongs to the group, not to a character of it. Where there is no visible label,
put aria-label on OTPField.Root.
Beside the group sits a visually hidden <input> carrying the whole code. That
is what makes name, required, form and constraint validation behave the
way they would on any other control, and what a password manager or an SMS
autofill writes into.
Focusing a slot selects the character in it, so the next keypress replaces it rather than being rejected by a full slot. That is also why the selection colour is a documented knob: it is visible on every single focus, not only when someone drags across text.
Under forced colours every variant paints a full 1px FieldText box, including
soft (whose border is transparent, a value forced-colors replaces with a
system colour of its own choosing) and underline (which has no border on three
sides at all). Keeping the slots countable is the whole job there — a user who
cannot see how many characters the code has cannot tell whether they have
finished typing it.
Motion. There is none, and that is a decision rather than an omission. The only thing that changes as the user types is which slot holds a character, and a slot that grew or flashed on fill would do it six times in two seconds, right next to the caret they are watching. What does transition is colour: the boundary on hover, on completion, and on failure.
Theming
Every --forte-otp-field-* property is declared on the field's root and reaches
the slots by inheritance — a slot is not something you address one at a time.
Set them on the root, through className (an unlayered rule beats
@layer forte.components whatever its specificity) or an inline style.
The global tokens they resolve to — --forte-color-*, --forte-control-*,
--forte-radius-* — are the exception: this component only reads those, so
re-pointing them from :root or a theme scope moves every field at once.
| Property | Controls | Default |
|---|---|---|
--forte-otp-field-slot-size | Slot width and height — the slots are square (reset per size) | var(--forte-control-h-md) |
--forte-otp-field-font-size | Font size of the character in a slot (reset per size) | var(--forte-font-size-3) |
--forte-otp-field-gap | Gap between slots | var(--forte-space-2) |
--forte-otp-field-separator-length | Length of the dash OTPField.Separator draws between slot groups | var(--forte-space-3) |
--forte-otp-field-radius | Slot corner radius (0px for variant="underline") | var(--forte-radius-control) |
--forte-otp-field-border-width | Slot border width (reset per variant) | 1px |
--forte-otp-field-bg | Slot background (reset per variant) | var(--forte-color-background) |
--forte-otp-field-bg-hover | Slot background on hover | var(--forte-color-panel-hover) |
--forte-otp-field-fg | Character colour | var(--forte-color-foreground) |
--forte-otp-field-border-color | Slot border colour (reset per variant, and again when the value is complete or the field is invalid) | var(--forte-color-border) |
--forte-otp-field-border-color-hover | Slot border colour on hover | var(--forte-color-border-strong) |
--forte-otp-field-caret-color | Caret colour | var(--forte-color-primary) |
--forte-otp-field-selection-bg | Selection background — visible on every focus, because Base UI selects a slot's character when you land on it so the next keypress replaces it | var(--forte-color-primary-soft) |
--forte-otp-field-selection-fg | Selection text colour | var(--forte-color-primary-text) |
--forte-otp-field-separator-color | Colour of the dash OTPField.Separator draws | var(--forte-color-border-strong) |
--forte-otp-field-gap is deliberately not --forte-control-gap. That token drops
to 4px under data-forte-density="compact", and 4px is less than a focus ring
needs to clear its slot (2px offset plus 2px width), so a focused slot's ring
would sit on top of its neighbour's border.
API reference
OTPField.Root
| Prop | Type | Default | Description |
|---|---|---|---|
length* | number | How many characters the code has, and therefore how many slots the field renders. Required: the root clamps the value, decides when it is complete and writes the hidden input's `pattern` from it, all of which have to be right on the server render, before a single slot has mounted. When you pass your own children, the number of `<OTPField.Input>`s has to match — Base UI warns in development when it does not. | |
autoSubmit | boolean | false | Submit the owning form as soon as the last slot is filled. The reason to want it is that the code is the whole form — there is nothing left to review, and a Verify button is a step whose only content is "yes, I meant the six digits I just typed". `onValueComplete` runs immediately before the submit, so you can still stop it. |
children | ReactNode | The slots, and anything between them. Leave it out and the root renders `length` plain `<OTPField.Input>`s for you — the common case, and the one where Base UI otherwise warns at runtime if the count and `length` disagree. Pass children when the row is not uniform: groups split by an `<OTPField.Separator />`, or a slot with its own `aria-label`. The count still has to equal `length`. | |
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
mask | boolean | false | Replace each character with a dot, the way a password field does. Off by default, and worth leaving off. A code that is read off a phone and typed once has nothing to shoulder-surf that the SMS did not already expose, and hiding it takes away the user's only way to check what they typed. |
size | OTPFieldSize | md | Size of the slots. Sets their square side and the character's font size together, and the side comes from the same `--forte-control-*` tokens `Input` and `Select.Trigger` read — so a code field lines up with the rest of a form at every `data-forte-density` setting. |
validationType | OTPFieldValidationType | numeric | Which characters the field accepts. Anything else is dropped as it is typed or pasted and reported through `onValueInvalid`, so a code copied with a stray space or a dash still lands correctly. It also picks the virtual keyboard: `numeric` asks for the digit pad, which is most of why a phone is bearable to type a code on. Override `inputMode` if you need a different one. |
variant | OTPFieldVariant | outline | How much visual weight each slot carries. `outline` reads as a form control, `soft` as a filled field, `underline` as a row of rules. There is no `ghost`, unlike `Input`: a slot with no boundary leaves nothing to count, and how many characters the code has is the one thing the field has to say before anything is typed. |
Everything else Base UI's OTPField.Root accepts passes through — value,
defaultValue, onValueChange, onValueComplete, onValueInvalid, name,
form, required, disabled, readOnly, id, autoComplete, inputMode,
normalizeValue and render.
OTPField.Input
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |
Slots take their index from the order they are rendered in, so there is no
index prop to keep in step — but the number of them has to equal the root's
length, and Base UI warns in development when it does not. Native <input>
props and Base UI's render pass through.
OTPField.Separator
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
decorative | boolean | true | Drops `role="separator"`, leaving the dash visible but absent from the accessibility tree. Defaults to `true` here, which is the opposite of a standalone `Separator`: the slots either side of it are one value and one control, so the grouping is a reading aid for the eye only. Announcing a boundary in the middle of a six-digit code describes the layout, not the field. |
orientation | SeparatorOrientation | horizontal | Which way the rule runs — and therefore which axis it separates. A `horizontal` rule is a line across the inline axis dividing stacked content; a `vertical` one is a line down the block axis dividing content in a row. It is also what Base UI reports as `aria-orientation`, so it is a semantic choice, not only a visual one. |
variant | SeparatorVariant | solid | The line style. A rule has exactly one visual dimension, so this is the whole of it: `solid` for structural divisions, `dashed` or `dotted` for softer, more provisional ones (a drop zone's edge, an optional section). |
It renders the library's Separator and takes all of
its props, so it carries data-forte="separator" rather than a marker of its own —
scope to it with [data-forte="otp-field"] [data-forte="separator"].