Skip to content

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.

otp-field/basic.tsx

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.

otp-field/sizes.tsx
sm
md
lg

Variants

outline reads as a form control, soft as a filled field, underline as a row of rules.

otp-field/variants.tsx
outline
soft
underline

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.

otp-field/grouped.tsx

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.

otp-field/masked.tsx

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.

otp-field/states.tsx
That code has expired. Request a new one.

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.

otp-field/auto-submit.tsx

Try 482913, or anything else to watch it fail.

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.

otp-field/controlled.tsx

(empty) · 0/6

Accessibility

Keyboard interactions
KeyBehaviour
TabMoves 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.
BackspaceClears the current slot, or moves back and clears the previous one when the current slot is already empty.
DeleteClears the current slot without moving.
Home then EndJump to the first or last slot.
Ctrl then VPastes 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.

Theming tokens for OTPField
PropertyControlsDefault
--forte-otp-field-slot-sizeSlot width and height — the slots are square (reset per size)var(--forte-control-h-md)
--forte-otp-field-font-sizeFont size of the character in a slot (reset per size)var(--forte-font-size-3)
--forte-otp-field-gapGap between slotsvar(--forte-space-2)
--forte-otp-field-separator-lengthLength of the dash OTPField.Separator draws between slot groupsvar(--forte-space-3)
--forte-otp-field-radiusSlot corner radius (0px for variant="underline")var(--forte-radius-control)
--forte-otp-field-border-widthSlot border width (reset per variant)1px
--forte-otp-field-bgSlot background (reset per variant)var(--forte-color-background)
--forte-otp-field-bg-hoverSlot background on hovervar(--forte-color-panel-hover)
--forte-otp-field-fgCharacter colourvar(--forte-color-foreground)
--forte-otp-field-border-colorSlot 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-hoverSlot border colour on hovervar(--forte-color-border-strong)
--forte-otp-field-caret-colorCaret colourvar(--forte-color-primary)
--forte-otp-field-selection-bgSelection background — visible on every focus, because Base UI selects a slot's character when you land on it so the next keypress replaces itvar(--forte-color-primary-soft)
--forte-otp-field-selection-fgSelection text colourvar(--forte-color-primary-text)
--forte-otp-field-separator-colorColour of the dash OTPField.Separator drawsvar(--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

Props for OTPFieldRoot
PropTypeDefaultDescription
length*numberHow 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.
autoSubmitbooleanfalseSubmit 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.
childrenReactNodeThe 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`.
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
maskbooleanfalseReplace 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.
sizeOTPFieldSizemdSize 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.
validationTypeOTPFieldValidationTypenumericWhich 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.
variantOTPFieldVariantoutlineHow 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

Props for OTPFieldInput
PropTypeDefaultDescription
classNamestringAdditional 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

Props for OTPFieldSeparator
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
decorativebooleantrueDrops `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.
orientationSeparatorOrientationhorizontalWhich 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.
variantSeparatorVariantsolidThe 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"].