OTP Field
A one-time password input composed of individual character slots.
Enter the 6-character code we sent to your device.
import { createUniqueId } from 'solid-js';
import { OTPField } from 'base-ui-solid/otp-field';
import styles from './index.module.css';
const OTP_LENGTH = 6;
export default function ExampleOTPField() {
const id = createUniqueId();
const descriptionId = `${id}-description`;
return (
<div class={styles.Field}>
<label for={id} class={styles.Label}>
Verification code
</label>
<OTPField.Root
id={id}
length={OTP_LENGTH}
aria-describedby={descriptionId}
class={styles.Root}
>
{Array.from({ length: OTP_LENGTH }, (_, index) => (
<OTPField.Input
class={styles.Input}
aria-label={index === 0 ? undefined : `Character ${index + 1} of ${OTP_LENGTH}`}
/>
))}
</OTPField.Root>
<p id={descriptionId} class={styles.Description}>
Enter the 6-character code we sent to your device.
</p>
</div>
);
}
Usage guidelines
- Form controls must have an accessible name: It can be created using a
<label>element or theFieldcomponent. See Labeling an OTP field and the forms guide.
Anatomy
Import the component and assemble its parts:
import { OTPField } from 'base-ui-solid/otp-field';
<OTPField.Root>
<OTPField.Input />
<OTPField.Separator />
</OTPField.Root>;
Examples
Labeling an OTP field
Pass an id to <OTPField.Root> and use a native <label> with a matching for. Let the
first input use the field label, and add aria-label to the remaining inputs so assistive
technology can announce which slot is focused.
Optionally, add aria-describedby when supporting text should be announced with the field.
<div>
<label for="verification-code">Verification code</label>
<OTPField.Root id="verification-code" length={6} aria-describedby="verification-code-description">
<OTPField.Input />
<OTPField.Input aria-label="Character 2 of 6" />
<OTPField.Input aria-label="Character 3 of 6" />
<OTPField.Input aria-label="Character 4 of 6" />
<OTPField.Input aria-label="Character 5 of 6" />
<OTPField.Input aria-label="Character 6 of 6" />
</OTPField.Root>
<p id="verification-code-description">Enter the 6-character code we sent to your device.</p>
</div>
Form integration
Use Field to handle label associations and form integration:
<Form>
<Field.Root name="verificationCode">
<Field.Label>Verification code</Field.Label>
<Field.Description>Enter the 6-character code we sent to your device.</Field.Description>
<OTPField.Root length={6}>
<OTPField.Input />
<OTPField.Input aria-label="Character 2 of 6" />
<OTPField.Input aria-label="Character 3 of 6" />
<OTPField.Input aria-label="Character 4 of 6" />
<OTPField.Input aria-label="Character 5 of 6" />
<OTPField.Input aria-label="Character 6 of 6" />
</OTPField.Root>
</Field.Root>
</Form>
Pass autoSubmit to submit the owning form automatically when all slots are filled, or use
onValueComplete to react to completion without submitting.
Alphanumeric verification codes
Use validationType="alphanumeric" for recovery, backup, or invite codes that mix letters and
numbers.
Accept letters and numbers for backup codes such as A7C9XZ.
import { createUniqueId } from 'solid-js';
import { OTPField } from 'base-ui-solid/otp-field';
import styles from './index.module.css';
const CODE_LENGTH = 6;
export default function OTPFieldAlphanumericDemo() {
const id = createUniqueId();
const descriptionId = `${id}-description`;
return (
<div class={styles.Field}>
<label for={id} class={styles.Label}>
Recovery code
</label>
<OTPField.Root
id={id}
length={CODE_LENGTH}
validationType="alphanumeric"
aria-describedby={descriptionId}
class={styles.Root}
>
{Array.from({ length: CODE_LENGTH }, (_, index) => (
<OTPField.Input
class={styles.Input}
aria-label={index === 0 ? undefined : `Character ${index + 1} of ${CODE_LENGTH}`}
/>
))}
</OTPField.Root>
<p id={descriptionId} class={styles.Description}>
Accept letters and numbers for backup codes such as <span class={styles.Code}>A7C9XZ</span>.
</p>
</div>
);
}
Grouped layouts
Wrap subsets of inputs in your own layout elements and use <OTPField.Separator> when you
want the code presented in smaller visual chunks such as 123-456.
import { createUniqueId } from 'solid-js';
import { OTPField } from 'base-ui-solid/otp-field';
import styles from './index.module.css';
const OTP_LENGTH = 6;
export default function OTPFieldGroupedDemo() {
const id = createUniqueId();
return (
<div class={styles.Field}>
<label for={id} class={styles.Label}>
Verification code
</label>
<OTPField.Root id={id} length={OTP_LENGTH} class={styles.Root}>
<div class={styles.Group}>
{Array.from({ length: 3 }, (_, index) => (
<OTPField.Input
class={styles.Input}
aria-label={index === 0 ? undefined : `Character ${index + 1} of ${OTP_LENGTH}`}
/>
))}
</div>
<OTPField.Separator class={styles.Separator} />
<div class={styles.Group}>
{Array.from({ length: 3 }, (_, index) => (
<OTPField.Input
class={styles.Input}
aria-label={`Character ${index + 4} of ${OTP_LENGTH}`}
/>
))}
</div>
</OTPField.Root>
</div>
);
}
Placeholder hints
<OTPField.Input> is a real input, so native placeholder props and CSS work as usual. This
example keeps placeholder hints visible until the active slot receives focus.
Placeholder hints can stay visible until the active slot is focused.
import { createUniqueId } from 'solid-js';
import { OTPField } from 'base-ui-solid/otp-field';
import styles from './index.module.css';
const CODE_LENGTH = 6;
export default function OTPFieldFocusedPlaceholderDemo() {
const id = createUniqueId();
const descriptionId = `${id}-description`;
return (
<div class={styles.Field}>
<label for={id} class={styles.Label}>
Verification code
</label>
<OTPField.Root
id={id}
length={CODE_LENGTH}
aria-describedby={descriptionId}
class={styles.Root}
>
{Array.from({ length: CODE_LENGTH }, (_, index) => (
<OTPField.Input
class={styles.Input}
placeholder="•"
aria-label={index === 0 ? undefined : `Character ${index + 1} of ${CODE_LENGTH}`}
/>
))}
</OTPField.Root>
<p id={descriptionId} class={styles.Description}>
Placeholder hints can stay visible until the active slot is focused.
</p>
</div>
);
}
Custom normalization
Use normalizeValue to normalize accepted values before state updates, such as converting
alphanumeric codes to uppercase. It runs after validationType filtering, and the result is filtered
against validationType again. Use validationType="none" when the normalizer should provide the
full validation rule.
Pair custom rules with inputmode for keyboard hints and onValueInvalid for rejected characters.
Letters and digits only. Letters are converted to uppercase.
import { createUniqueId } from 'solid-js';
import { OTPField } from 'base-ui-solid/otp-field';
import { useInvalidFeedback } from './useInvalidFeedback';
import styles from './index.module.css';
const CODE_LENGTH = 6;
function normalizeRecoveryCode(value: string) {
return value.toUpperCase();
}
function getInvalidClassName(invalidPulse: number, evenClassName: string, oddClassName: string) {
if (invalidPulse === 0) {
return '';
}
return invalidPulse % 2 === 0 ? evenClassName : oddClassName;
}
export default function OTPFieldCustomNormalizeDemo() {
const id = createUniqueId();
const descriptionId = `${id}-description`;
const {
activeInvalidIndex,
handleValueChange,
handleValueInvalid,
invalidPulse,
setFocusedIndex,
statusMessage,
} = useInvalidFeedback();
const invalidClassName = () =>
getInvalidClassName(invalidPulse(), styles.InputInvalidB, styles.InputInvalidA);
return (
<div class={styles.Field}>
<label for={id} class={styles.Label}>
Recovery code
</label>
<OTPField.Root
id={id}
length={CODE_LENGTH}
validationType="alphanumeric"
normalizeValue={normalizeRecoveryCode}
onValueChange={handleValueChange}
onValueInvalid={handleValueInvalid}
aria-describedby={descriptionId}
class={styles.Root}
>
{Array.from({ length: CODE_LENGTH }, (_, index) => (
<OTPField.Input
class={[styles.Input, activeInvalidIndex() === index ? invalidClassName() : undefined]}
aria-label={index === 0 ? undefined : `Character ${index + 1} of ${CODE_LENGTH}`}
onFocusIn={() => {
setFocusedIndex(index);
}}
/>
))}
</OTPField.Root>
<p id={descriptionId} class={styles.Description}>
Letters and digits only. Letters are converted to uppercase.
</p>
<span aria-live="polite" class={styles.ScreenReaderOnly}>
{statusMessage()}
</span>
</div>
);
}
Masked entry
Use mask when the code should be obscured while it is being typed.
Use mask to obscure the code on shared screens.
import { createUniqueId } from 'solid-js';
import { OTPField } from 'base-ui-solid/otp-field';
import styles from './index.module.css';
const CODE_LENGTH = 6;
export default function OTPFieldPasswordDemo() {
const id = createUniqueId();
const descriptionId = `${id}-description`;
return (
<div class={styles.Field}>
<label for={id} class={styles.Label}>
Access code
</label>
<OTPField.Root
id={id}
length={CODE_LENGTH}
mask
aria-describedby={descriptionId}
class={styles.Root}
>
{Array.from({ length: CODE_LENGTH }, (_, index) => (
<OTPField.Input
class={styles.Input}
aria-label={index === 0 ? undefined : `Character ${index + 1} of ${CODE_LENGTH}`}
/>
))}
</OTPField.Root>
<p id={descriptionId} class={styles.Description}>
Use <span class={styles.Code}>mask</span> to obscure the code on shared screens.
</p>
</div>
);
}
API reference
Root
Groups all OTP field parts and manages their state.
Renders a <div> element.
namestring—
stringdefaultValuestring—
stringvaluestring—
stringonValueChangefunction—
eventDetails.reason indicates what triggered the change: 'input-change' for typing or autofill'input-clear' when a character is removed by text input'input-paste' for paste interactions'keyboard' for keyboard interactions that change the value((value: string, eventDetails: OTPField.Root.ChangeEventDetails) => void)autoCompletestring'one-time-code'
stringautoSubmitbooleanfalse
booleanformstring—
form element with which the hidden input is associated.
This string’s value must match the id of a form element in the same document.stringinputModeUnion—
'none' | 'text' | 'tel' | 'url' | 'email' | 'numeric' | 'decimal' | 'search'length*number—
numbermaskbooleanfalse
type directly to individual <OTPField.Input> parts to use a custom
input type.booleannormalizeValuefunction—
validationType filtering.
It runs whenever OTP Field normalizes a value, including initial/default values, controlled
values, and user edits. The returned value is filtered by validationType again, then clamped to length.
It should be idempotent because OTP Field may normalize the same value more than once while
handling edits, storing state, and rendering controlled or uncontrolled values. Non-idempotent
normalizers can compound across those normalization passes. Characters rejected while
normalizing typed or pasted text are reported through onValueInvalid.((value: string) => string)onValueCompletefunction—
onValueChange, after the internal value update is
applied. If a complete pasted value matches the current value, onValueChange does not fire. If autoSubmit is enabled, it runs immediately before the owning form is submitted.((value: string, eventDetails: OTPField.Root.CompleteEventDetails) => void)onValueInvalidfunction—
value argument is the attempted user-entered string before normalization.((value: string, eventDetails: OTPField.Root.InvalidEventDetails) => void)validationTypeOTPField.Root.ValidationType'numeric'
OTPField.Root.ValidationTypedisabledbooleanfalse
booleanreadOnlybooleanfalse
booleanrequiredbooleanfalse
booleanidstring—
{id}-2, {id}-3, and so on).stringclassfunction—
JSX.ClassValue | ((state) => JSX.ClassValue)stylefunction—
JSX.CSSProperties | string | ((state) => JSX.CSSProperties | string | undefined)renderfunction—
keyof JSX.IntrinsicElements | Component | ((props, state) => JSX.Element)Data attributes
data-disabled-—
-data-readonly-—
-data-required-—
-data-valid-—
-data-invalid-—
-data-dirty-—
-data-touched-—
-data-complete-—
-data-filled-—
-data-focused-—
-Attribute | Description | |
|---|---|---|
data-disabled | Present when the OTP field is disabled. | |
data-readonly | Present when the OTP field is readonly. | |
data-required | Present when the OTP field is required. | |
data-valid | Present when the OTP field is in a valid state (when wrapped in Field.Root). | |
data-invalid | Present when the OTP field is in an invalid state (when wrapped in Field.Root). | |
data-dirty | Present when the OTP field’s value has changed (when wrapped in Field.Root). | |
data-touched | Present when the OTP field has been touched (when wrapped in Field.Root). | |
data-complete | Present when all slots are filled. | |
data-filled | Present when the OTP field contains at least one character. | |
data-focused | Present when one of the OTP field inputs is focused. | |
Root.State
type OTPFieldRootState = {
/** Whether all slots are filled. */
complete: boolean;
/** Whether the component should ignore user interaction. */
disabled: boolean;
/** The number of OTP input slots. */
length: number;
/** Whether the user should be unable to change the field value. */
readOnly: boolean;
/** Whether the user must enter a value before submitting a form. */
required: boolean;
/** The OTP value. */
value: string;
/** Whether the field has been touched. */
touched: boolean;
/** Whether the field value has changed from its initial value. */
dirty: boolean;
/** Whether the field is valid. */
valid: boolean | null;
/** Whether the field has a value. */
filled: boolean;
/** Whether the field is focused. */
focused: boolean;
};Root.ChangeEventReason
type OTPFieldRootChangeEventReason = 'input-change' | 'input-clear' | 'input-paste' | 'keyboard';Root.ChangeEventDetails
type OTPFieldRootChangeEventDetails = (
| { reason: 'input-change'; event: InputEvent | Event }
| { reason: 'input-clear'; event: InputEvent | Event | FocusEvent }
| { reason: 'input-paste'; event: ClipboardEvent }
| { reason: 'keyboard'; event: KeyboardEvent }
) & {
/** Cancels Base UI from handling the event. */
cancel: () => void;
/** Allows the event to propagate in cases where Base UI will stop the propagation. */
allowPropagation: () => void;
/** Indicates whether the event has been canceled. */
isCanceled: boolean;
/** Indicates whether the event is allowed to propagate. */
isPropagationAllowed: boolean;
/** The element that triggered the event, if applicable. */
trigger: Element | undefined;
};Root.CompleteEventDetails
type OTPFieldRootCompleteEventDetails =
| { reason: 'input-change'; event: InputEvent | Event }
| { reason: 'input-paste'; event: ClipboardEvent };Root.CompleteEventReason
type OTPFieldRootCompleteEventReason = 'input-change' | 'input-paste';Root.InvalidEventDetails
type OTPFieldRootInvalidEventDetails =
| { reason: 'input-change'; event: InputEvent | Event }
| { reason: 'input-paste'; event: ClipboardEvent };Root.InvalidEventReason
type OTPFieldRootInvalidEventReason = 'input-change' | 'input-paste';Root.ValidationType
type OTPFieldRootValidationType = 'numeric' | 'alpha' | 'alphanumeric' | 'none';Input
An individual OTP character input.
Renders an <input> element.
classfunction—
JSX.ClassValue | ((state) => JSX.ClassValue)stylefunction—
JSX.CSSProperties | string | ((state) => JSX.CSSProperties | string | undefined)renderfunction—
keyof JSX.IntrinsicElements | Component | ((props, state) => JSX.Element)Data attributes
data-disabled-—
-data-readonly-—
-data-required-—
-data-valid-—
-data-invalid-—
-data-dirty-—
-data-touched-—
-data-complete-—
-data-filled-—
-data-focused-—
-Attribute | Description | |
|---|---|---|
data-disabled | Present when the OTP field is disabled. | |
data-readonly | Present when the OTP field is readonly. | |
data-required | Present when the OTP field is required. | |
data-valid | Present when the OTP field is in a valid state (when wrapped in Field.Root). | |
data-invalid | Present when the OTP field is in an invalid state (when wrapped in Field.Root). | |
data-dirty | Present when the OTP field’s value has changed (when wrapped in Field.Root). | |
data-touched | Present when the OTP field has been touched (when wrapped in Field.Root). | |
data-complete | Present when all slots are filled. | |
data-filled | Present when the input contains a character. | |
data-focused | Present when any OTP field input is focused. | |
Input.State
type OTPFieldInputState = {
/** Whether this input contains a character. */
filled: boolean;
/** The input index. */
index: number;
/** The character rendered in this slot. */
value: string;
/** Whether the component should ignore user interaction. */
disabled: boolean;
/** The number of OTP input slots. */
length: number;
/** Whether the user must enter a value before submitting a form. */
required: boolean;
/** Whether the user should be unable to change the field value. */
readOnly: boolean;
/** Whether all slots are filled. */
complete: boolean;
/** Whether the field has been touched. */
touched: boolean;
/** Whether the field value has changed from its initial value. */
dirty: boolean;
/** Whether the field is valid. */
valid: boolean | null;
/** Whether the field is focused. */
focused: boolean;
};Separator
A separator element accessible to screen readers.
Renders a <div> element.
orientationOrientation'horizontal'
Orientationclassfunction—
JSX.ClassValue | ((state) => JSX.ClassValue)stylefunction—
JSX.CSSProperties | string | ((state) => JSX.CSSProperties | string | undefined)renderfunction—
keyof JSX.IntrinsicElements | Component | ((props, state) => JSX.Element)Data attributes
data-orientationUnion—
'horizontal' | 'vertical'Attribute | Description | |
|---|---|---|
data-orientation | Indicates the orientation of the separator. | |
Separator.State
type OTPFieldSeparatorState = {
/** The orientation of the separator. */
orientation: Orientation;
};