OTP Input
A multi-slot one-time-passcode input — keyboard navigation, paste distribution, autofill, masking, and a disabled state, built on Base UI OTP Field.
- Status
- Since
0.1.0- Accessibility pattern
- grouped one-time-code inputs
Last updated
Install
Add OTP Input from the VegaStack registry. The CLI verifies the item's integrity hash before writing it.
pnpm dlx shadcn@latest add @vegastack/otp-inputUsage
import { OTPInput } from "@/components/ui/otp-input";
<OTPInput aria-label="Verification code" />;OTPInput renders length square slots (default 6), each a real focusable
<input>. It is numeric by default and handles keyboard navigation
(arrows / backspace / delete), distributes a pasted code across the slots, and
autofills a browser-suggested one-time-code.
Control it with value + onValueChange, or leave it uncontrolled with
defaultValue. Use onValueComplete to react when every slot is filled — ideal
for auto-submitting a verification step:
const [code, setCode] = useState("");
<OTPInput
aria-label="Verification code"
value={code}
onValueChange={setCode}
onValueComplete={(value) => verify(value)}
/>;Examples
States
OTPInput renders an empty row (every cell border-input), a filled
value, a masked value (mask), an invalid row (slots tint destructive —
the data-invalid state), and a fully disabled field. The focused slot
raises above its neighbors and recolors its border to the ring token.
Grouped
Use groups to render Base UI OTPField.Separator parts between slot groups.
When length is omitted, the group total defines the length. The default
separator is -; pass a separator node and separatorClassName to customize
it (see Length & separator).
<OTPInput aria-label="Verification code" groups={[3, 3]} />Length & separator
length sets the number of slots (default 6) when no groups are supplied.
separator replaces the default - node between groups, and separatorClassName
merges extra classes onto every separator.
<OTPInput aria-label="Four-digit PIN" length={4} />
<OTPInput
aria-label="Verification code"
groups={[3, 3]}
separator="·"
separatorClassName="text-primary"
/>;With a label
Pair OTPInput with a Field to attach a visible label, description, and an
accessible error message. The field's label is auto-associated with the first
slot; a Field error tints the slots destructive and renders an alert.
import { Field } from "@/components/ui/field";
import { OTPInput } from "@/components/ui/otp-input";
<Field
label="Verification code"
description="Enter the 6-digit code we sent you."
>
<OTPInput />
</Field>;Enter the 6-digit code we sent you.
Auto-submit on complete
onValueComplete fires the moment every slot is filled — wire it to trigger a
verification step. This example is live: fill all six slots to fire the
callback.
Fill all six slots to fire onValueComplete.
Playground
Try the 4- and 6-digit lengths, the three slot sizes, and the mask and disabled states, then copy the generated JSX.
<OTPInput aria-label="Verification code" />API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
aria-label | string | — | Accessible name for the field, applied to the first slot. Use this when there
is no visible <label>/FieldLabel wired to the input. |
className | string | — | Extra classes for the slot row (the OTPField.Root <div>). |
defaultValue | string | — | The uncontrolled initial value. |
disabled | boolean | false | Disable the whole field — every slot becomes non-interactive and dimmed. |
groups | readonly number[] | — | Optional slot grouping for layouts like 123-456. When omitted, slots are
rendered as one flat group. If supplied, the positive numbers must add up to
length (or they define length when the length prop is omitted). |
length | number | 6 | Number of character slots to render. |
mask | boolean | false | Mask entered characters (renders each slot as a password input). |
onValueChange | ((value: string, eventDetails: OTPField.Root.ChangeEventDetails) => void) | — | Callback fired when the value changes. The second argument is Base UI's
event-details object (eventDetails.reason is 'input-change',
'input-clear', 'input-paste', or 'keyboard'). |
onValueComplete | ((value: string, eventDetails: OTPField.Root.CompleteEventDetails) => void) | — | Fired when every slot is filled — use it to auto-submit a verification code. |
separator | React.ReactNode | '-' | Visual content rendered between OTP groups. |
separatorClassName | string | — | Extra classes merged into every group separator. |
size | "lg" | "md" | "sm" | 'md' | Slot size on the shared 28/32/40 control scale (register P1-04). |
slotClassName | string | — | Extra classes merged into every slot <input>. |
value | string | — | The OTP value (controlled). Pair with OTPInputProps.onValueChange. |
Data attributes and CSS variables on OTPInput
| Attribute | Values |
|---|---|
data-slot | "otp-input" | "otp-input-separator" | "otp-input-slot" |
Accessibility
- Each slot is a real
<input>, so the field is fully keyboard operable: type to advance,Backspace/Deleteto clear, and←/→to move between slots. - Always give the field an accessible name — compose it with
Field, associate an external label, or passaria-label. The name labels the first slot; later slots are auto-labeledCharacter N of Mfor screen-reader context. (Base UI ignoresaria-labelplaced directly on the first slot, soOTPInputwires it through a visually-hidden explicit label. Do not wrap the whole multi-input group in one native<label>.) - Grouped layouts use Base UI
OTPField.Separator; separators are visual grouping aids while each slot keeps its own positional label. - On focus the active slot raises its stacking order and recolors its border to
the
ringtoken (focus:z-(--z-raised) focus:border-ring/(--alpha-tint-border)). The darkened border is the sole focus cue — no ring, matchingInputand the other text-entry fields — neveroutline: nonewith no replacement. - Paste is supported: pasting a full code fills every slot at once and fires
onValueComplete. - Set
maskto obscure entered characters (each slot becomes a password input) for sensitive codes.
| Contract | States tested |
|---|---|
| Behaviour | default, complete, disabled, dragging, error, invalid |
| Accessibility | invalid, labeled, semantic-html |
| Visual | default, focus, disabled, invalid, error, dark |