OTP Field
Entry for one-time passwords and verification codes.
Enter the 6-digit code. Value: (empty)
Codeapp/demos/public/otp-field.tsx
import { clientEntry, on, type Handle } from 'remix/component'
import {
OTPField,
OTPFieldGroup,
OTPFieldSeparator,
OTPFieldSlot,
} from '@stemsui/registry/ui/otp-field.tsx'
export const OtpFieldDemo = clientEntry(import.meta.url, function OtpFieldDemo(handle: Handle) {
let code = ''
let completed: string | null = null
let submitted: string | null = null
return () => (
<div class="flex w-full max-w-sm flex-col gap-8">
<div class="flex flex-col gap-2">
<label for="verification-code" class="text-sm leading-none font-medium select-none">
Verification code
</label>
<OTPField
id="verification-code"
length={6}
value={code}
aria-describedby="verification-code-description"
onValueChange={(value) => {
code = value
void handle.update()
}}
onValueComplete={(value) => {
completed = value
void handle.update()
}}
>
<OTPFieldGroup>
<OTPFieldSlot index={0} />
<OTPFieldSlot index={1} aria-label="Character 2 of 6" />
<OTPFieldSlot index={2} aria-label="Character 3 of 6" />
</OTPFieldGroup>
<OTPFieldSeparator />
<OTPFieldGroup>
<OTPFieldSlot index={3} aria-label="Character 4 of 6" />
<OTPFieldSlot index={4} aria-label="Character 5 of 6" />
<OTPFieldSlot index={5} aria-label="Character 6 of 6" />
</OTPFieldGroup>
</OTPField>
<p id="verification-code-description" class="text-sm text-muted-foreground">
Enter the 6-digit code.{' '}
<span data-testid="otp-state">
Value: <code class="font-mono text-foreground">{code || '(empty)'}</code>
{completed ? ` · completed ${completed}` : ''}
</span>
</p>
</div>
<div class="flex flex-col gap-2">
<label for="recovery-code" class="text-sm leading-none font-medium select-none">
Recovery code (letters and digits, masked)
</label>
<OTPField
id="recovery-code"
length={4}
validationType="alphanumeric"
normalizeValue={(value) => value.toUpperCase()}
mask
>
<OTPFieldGroup>
<OTPFieldSlot index={0} />
<OTPFieldSlot index={1} aria-label="Character 2 of 4" />
<OTPFieldSlot index={2} aria-label="Character 3 of 4" />
<OTPFieldSlot index={3} aria-label="Character 4 of 4" />
</OTPFieldGroup>
</OTPField>
</div>
<form
class="flex flex-col gap-3 rounded-lg border p-4"
mix={on('submit', (event) => {
event.preventDefault()
let entries = [...new FormData(event.currentTarget).entries()]
submitted = entries.map(([key, value]) => `${key}=${String(value)}`).join('&')
void handle.update()
})}
>
<label for="pin" class="text-sm leading-none font-medium select-none">
PIN (submits when complete)
</label>
<OTPField id="pin" name="pin" length={4} required autoSubmit>
<OTPFieldGroup>
<OTPFieldSlot index={0} />
<OTPFieldSlot index={1} aria-label="Digit 2 of 4" />
<OTPFieldSlot index={2} aria-label="Digit 3 of 4" />
<OTPFieldSlot index={3} aria-label="Digit 4 of 4" />
</OTPFieldGroup>
</OTPField>
<p class="text-sm text-muted-foreground" data-testid="otp-form-result">
Submitted: <code class="font-mono text-foreground">{submitted ?? '(not yet)'}</code>
</p>
</form>
<OTPField length={4} defaultValue="12" disabled aria-label="Disabled code">
<OTPFieldGroup>
<OTPFieldSlot index={0} aria-label="Disabled character 1" />
<OTPFieldSlot index={1} aria-label="Disabled character 2" />
<OTPFieldSlot index={2} aria-label="Disabled character 3" />
<OTPFieldSlot index={3} aria-label="Disabled character 4" />
</OTPFieldGroup>
</OTPField>
</div>
)
})
Installation
npx shadcn add @stemsui/otp-fieldRequires the one-time setup. Behavior: stems-ui/otp-field.
API reference
The styled component is built on the headless module below: its props mirror these options, and the same events and data attributes apply.
stems-ui/otp-field
otp-field is a one-time-code input. It is made of one <input> per character slot.
Base UI equivalent: OTP Field.
Usage
import * as otpField from 'stems-ui/otp-field'
function VerificationCode() {
return () => (
<div>
<label for="code">Verification code</label>
<otpField.Context id="code" name="code" length={6}>
<div class="flex gap-2" mix={otpField.root()}>
{Array.from({ length: 6 }, (_, index) => (
<input
class="size-10 text-center"
aria-label={index === 0 ? undefined : `Character ${index + 1} of 6`}
mix={otpField.input({ index })}
/>
))}
</div>
</otpField.Context>
</div>
)
}
stems-ui/otp-field
<otpField.Context>props (Base UI'sOTPField.Rootprops):length(required): the number of slots.value/defaultValue:string. The value is normalized, then clamped tolength.onValueChange(value, details):detailshasreason('input-change' | 'input-clear' | 'input-paste' | 'keyboard'),event,trigger,cancel(), andisCanceled.onValueComplete(value, { reason, event }): fires when the value becomes complete. It also fires when a complete code is pasted into a field that is already complete.onValueInvalid(attemptedText, { reason, event }): fires when typed or pasted characters are rejected.validationType:'numeric'(default),'alpha','alphanumeric', or'none'.normalizeValue(value): runs after filtering. Its result is filtered again. It must be idempotent.mask: slots becometype="password".inputMode: defaults tonumericfor numeric codes andtextotherwise.autoComplete:'one-time-code'on the first slot. Other slots getoff.autoSubmit: submits the owning form (requestSubmit()) when the value becomes complete.disabled,readOnly,required,name,form.id: the first slot's id. Later slots use{id}-2,{id}-3, ...
otpField.root():role="group"with the state attributes.otpField.input(options?): a slot (use an<input>host). It setsid,type,inputmode,autocomplete,autocorrect="off",autocapitalize="off",spellcheck="false",value(the slot's character),tabindex,disabled, andreadonly.index: the slot's position. Without it, slots are numbered in render order.
otpField.separator(options?):role="separator"witharia-orientationanddata-orientation(orientation, default'horizontal').normalizeOTPValue(text, validationType, length, normalizeValue?): the filter, for use outside a Context. It returns{ value, rejected }.- Events:
stems:otp-field-change(OTPFieldChangeEvent:value,reason) andstems:otp-field-complete(OTPFieldCompleteEvent:value). Both bubble. Listen withonOTPFieldChange(handler)andonOTPFieldComplete(handler).
Behavior notes
- Tab order: only the slot that takes the next character (the first empty slot, or the last
slot when the code is complete) has
tabindex="0". Tab enters and leaves the field in one step. - Focus: focusing a slot past the first empty one moves focus back to the first empty slot. Focusing or clicking a slot selects its character, so typing replaces it.
- Typing a character moves focus to the next slot.
- When a slot ends up with two characters (typed next to an unselected character), the new character wins.
- When a slot receives several characters (autofill of
one-time-code, IME), they fill the slots from there.
- Rejected characters call
onValueInvalidand are dropped. Whitespace is always ignored. - Paste:
- A code at least
lengthlong replaces the whole value. - A shorter code is inserted at the focused slot.
- Focus moves to the slot after the pasted text.
- A code at least
- Keyboard:
- Backspace on a filled slot deletes its character. The characters after it shift left.
- Backspace on an empty slot deletes the previous character and moves focus there.
- Delete removes the slot's character. The characters after it shift left.
- ArrowLeft and ArrowRight move between slots, flipped in RTL.
- Home goes to the first slot. End goes to the first empty slot (or the last slot).
- The value is a contiguous string with no gaps. Deleting in the middle shifts later characters left.
- Form participation: the Context renders a visually hidden
<input>after its children. It hasaria-hiddenandtabindex="-1", and carriesname, the whole value,required, andpattern="[\s\S]{length}", so a partial code fails validation. When validation focuses it, focus moves to the first empty slot. - Labelling, as in Base UI: point a
<label for>at the Contextid(the first slot), and give the other slots anaria-label. - Known gaps:
- Select-all across slots (Ctrl/Cmd+A, then Backspace) isn't supported.
- On Android virtual keyboards, Backspace in an empty slot may not fire
keydown, so focus doesn't move back.
Field integration
Inside <field.Context> the OTP field is the field's control. The first slot gets the field's
control id (unless id is set, which the field then follows), so <label mix={field.label()}>
targets it, and the root is aria-labelledby the field label. Slots get aria-describedby and
aria-invalid. The hidden input gets the field's name and is the validity element:
valueMissing with required, patternMismatch for an incomplete code. The first empty slot is
focused when the field blocks a submission. Focus leaving the slots marks the field touched. The
field's (or an enclosing fieldset.Context's) disabled disables it.
Styling hooks
root and input:
| Attribute | Values |
|---|---|
data-complete | Present when every slot is filled. |
data-filled | Root: at least one character. Slot: the slot has a character. |
data-focused | Present while any slot has focus. |
data-disabled | Present when the field is disabled. |
data-readonly | Present when the field is read-only. |
data-required | Present when the field is required. |
data-valid / data-invalid | In a field: validated and valid / invalid. |
data-dirty | In a field: the value changed from the initial value. |
data-touched | In a field: focus left the slots once. |
separator has data-orientation. No CSS variables are set.