OTP Field

A one-time password input composed of individual character slots.

Installation

pnpm dlx shadcn@latest add @lumi-ui/otp-field
import { OTPField } from "@/components/ui/otp-field"
<OTPField length={6} />

Anatomy

 <OTPField length={6} />

Size

Alphanumeric

Use validationType="alphanumeric" for recovery, backup, or invite codes that mix letters and numbers. See Validation types for the full enum.

Grouped

Wrap subsets of inputs in your own layout elements and use <OTPFieldSeparator> when you want the code presented in smaller visual chunks such as 123-456. Requires primitive usage — the composite renders inputs only.

Masked

Use the mask prop to obscure every slot, or pass type="password" to individual <OTPFieldInput> parts for per-slot control.

Per-slot masking
<OTPFieldRoot length={6}>
  <OTPFieldInput />
  <OTPFieldInput />
  <OTPFieldInput type="password" />
  <OTPFieldInput type="password" />
  <OTPFieldInput type="password" />
  <OTPFieldInput type="password" />
</OTPFieldRoot>

Placeholder hints

<OTPFieldInput> 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.

Accessibility

Every OTP field must have an accessible name. Choose one of the two patterns below.

With a native label

Pass id to <OTPFieldRoot> and pair it with <label htmlFor>. The first slot picks up the label automatically; add aria-label to remaining slots so screen readers announce which character is focused. Use aria-describedby for supporting text.

<label htmlFor="verification-code">Verification code</label>
<OTPFieldRoot
  id="verification-code"
  length={6}
  aria-describedby="verification-code-help"
>
  <OTPFieldInput />
  <OTPFieldInput aria-label="Character 2 of 6" />
  <OTPFieldInput aria-label="Character 3 of 6" />
  <OTPFieldInput aria-label="Character 4 of 6" />
  <OTPFieldInput aria-label="Character 5 of 6" />
  <OTPFieldInput aria-label="Character 6 of 6" />
</OTPFieldRoot>
<p id="verification-code-help">
  Enter the 6-character code we sent to your device.
</p>

The composite <OTPField> already adds the per-slot aria-labels — when using it, you only need id + <label>.

With Field

Use <Field> to handle label association, descriptions, and form validation automatically:

<Field name="verificationCode">
  <FieldLabel>Verification code</FieldLabel>
  <FieldDescription>
    Enter the 6-digit code we sent to your device.
  </FieldDescription>
  <OTPField length={6} required />
  <FieldError />
</Field>

Custom sanitization

Set validationType="none" with sanitizeValue to normalize pasted values before they reach state. Use inputMode when a custom rule still needs a specific virtual keyboard hint, and onValueInvalid to react to rejected characters. This example accepts digits 0-3 only, shakes the focused slot on rejection, and announces what was dropped via aria-live.

Digits 0-3 only.

With Form

Enter the 6-digit code we sent to your device.

Pass autoSubmit to submit the owning form automatically when all slots are filled, or use onValueComplete to react to completion without submitting. See Complete & autoSubmit for the callback order.

See Form Integration for more information.

Native form submission

The form value is a single string of length length keyed by name on <OTPFieldRoot>:

<form action="/verify" method="post">
  <OTPField length={6} name="code" required />
</form>
// POST body: { code: "123456" }

Combine with autoSubmit to skip the explicit submit button entirely. For client-side libraries (React Hook Form, Conform, etc.), drive the field with value + onValueChange and validate against value.length === length.

Controlled vs uncontrolled

Uncontrolled — simplest
<OTPField defaultValue="123" length={6} onValueComplete={handleComplete} />
Controlled — value drives every render
const [value, setValue] = useState("");
 
<OTPField length={6} value={value} onValueChange={setValue} />

value and defaultValue are mutually exclusive. The value is always a single string up to length characters — never an array. The string only reaches length === value.length when the field is complete.

Complete & autoSubmit

Callback order on each keystroke or paste that fills the field:

  1. onValueChange(value, { reason }) — fires for typing, paste, clear, or keyboard nav that mutates the value.
  2. onValueComplete(value, { reason }) — fires once value.length === length, after the state update is applied. reason is 'input-change' | 'input-paste'.
  3. Form submit — only if autoSubmit is set. Runs immediately after onValueComplete.

onValueComplete fires whether or not autoSubmit is enabled — use it to advance a wizard step, call a verify API, or close a dialog without submitting a form.

API Reference

Components

ComponentDescription
OTPFieldRootGroups all OTP field parts and manages state. Renders a <div>.
OTPFieldInputAn individual character slot. Renders an <input>.
OTPFieldSeparatorA visual separator between slots or groups. Renders a <div>.
OTPFieldPre-assembled composite that renders Root with length inputs. Inputs only — drop to primitives for separators or grouped layouts.

OTPFieldRoot Props

PropTypeDefaultDescription
length *numberNumber of slots. Required.
validationType'numeric' | 'alpha' | 'alphanumeric' | 'none''numeric'Built-in input validation. See Validation types.
valuestringControlled value.
defaultValuestringInitial uncontrolled value.
onValueChange(value, details) => voidFires on every change. details.reason is 'input-change' | 'input-clear' | 'input-paste' | 'keyboard'.
onValueComplete(value, details) => voidFires once all slots are filled, after onValueChange. details.reason is 'input-change' | 'input-paste'.
onValueInvalid(value, details) => voidFires when sanitization rejects characters, before the value updates.
sanitizeValue(value: string) => stringCustom sanitizer. Only invoked when validationType="none".
autoSubmitbooleanfalseSubmit the owning form when the OTP becomes complete.
maskbooleanfalseMask entered characters on all slots.
autoCompletestring'one-time-code'Applied to the first slot and hidden validation input.
inputMode'numeric' | 'text' | 'tel' | 'email' | ...derived from validationTypeVirtual keyboard hint override.
namestringForm field name. The submitted value is a single string.
idstringid of the first input. Subsequent slots derive ${id}-2, ${id}-3, etc.
disabledbooleanfalseDisable all slots.
readOnlybooleanfalsePrevent value changes.
requiredbooleanfalseRequired for form submission.
formstringid of an external <form> to associate with.

OTPFieldInput Props

Renders an <input> element. Accepts all native input attributes (type, placeholder, aria-label, onFocus, etc.) plus the Base UI render-prop trio: className, style, render.

The wrapper in this registry adds an inputSize variant: 'sm' | 'default' | 'lg'.

Validation types

ValueAcceptsDefault inputMode
'numeric' (default)0-9numeric
'alpha'A-Z, a-ztext
'alphanumeric'A-Z, a-z, 0-9text
'none'Whatever sanitizeValue returnstext (override via inputMode)

State data attributes

<OTPFieldRoot> exposes:

AttributePresent when
data-completeAll slots are filled.
data-filledAt least one slot has a value.
data-focusedAny slot has focus.
data-disabledThe field is disabled.
data-readonlyThe field is read-only.
data-requiredThe field is required.
data-invalid / data-validInside <Field> after validation runs.
data-dirtyValue differs from the initial value.
data-touchedThe field has been focused at least once.

<OTPFieldInput> exposes a per-slot data-filled plus all of the Root attributes (mirrored for styling convenience).