Skip to content
Component installs need the registry setup— the Base UI shadcn project, the @vegastack namespace and the Cloudflare Access service token.
VegaStack Design

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
stable
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-input

Usage

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.

That code is incorrect.

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

PropTypeDefaultDescription
aria-labelstringAccessible name for the field, applied to the first slot. Use this when there is no visible <label>/FieldLabel wired to the input.
classNamestringExtra classes for the slot row (the OTPField.Root <div>).
defaultValuestringThe uncontrolled initial value.
disabledbooleanfalseDisable the whole field — every slot becomes non-interactive and dimmed.
groupsreadonly 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).
lengthnumber6Number of character slots to render.
maskbooleanfalseMask 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.
separatorReact.ReactNode'-'Visual content rendered between OTP groups.
separatorClassNamestringExtra classes merged into every group separator.
size"lg" | "md" | "sm"'md'Slot size on the shared 28/32/40 control scale (register P1-04).
slotClassNamestringExtra classes merged into every slot <input>.
valuestringThe OTP value (controlled). Pair with OTPInputProps.onValueChange.

Data attributes and CSS variables on OTPInput

AttributeValues
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/Delete to clear, and / to move between slots.
  • Always give the field an accessible name — compose it with Field, associate an external label, or pass aria-label. The name labels the first slot; later slots are auto-labeled Character N of M for screen-reader context. (Base UI ignores aria-label placed directly on the first slot, so OTPInput wires 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 ring token (focus:z-(--z-raised) focus:border-ring/(--alpha-tint-border)). The darkened border is the sole focus cue — no ring, matching Input and the other text-entry fields — never outline: none with no replacement.
  • Paste is supported: pasting a full code fills every slot at once and fires onValueComplete.
  • Set mask to obscure entered characters (each slot becomes a password input) for sensitive codes.
ContractStates tested
Behaviourdefault, complete, disabled, dragging, error, invalid
Accessibilityinvalid, labeled, semantic-html
Visualdefault, focus, disabled, invalid, error, dark

Do / Don't

Do
Give the field a label, use groups for common split-code layouts, and use onValueComplete to drive submission.
Don't
Render bare slots with no accessible name, or use multiple separate single-character inputs you wire up by hand.

On this page