Input OTP
A one-time-password field with per-character slots — copy/paste aware, patterned, separable, and driven by one hidden input.
- Status
- Since
0.10.0- Accessibility pattern
- single native input behind the slots
Last updated
Install
Add Input OTP from the VegaStack registry. The CLI verifies the item's integrity hash before writing it.
pnpm dlx shadcn@latest add @vegastack/input-otpIt also adds the sanctioned engine to your package.json: input-otp (one-time-password field state machine).
Usage
import {
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot,
} from "@/components/ui/input-otp";
<InputOTP maxLength={6}>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
</InputOTPGroup>
</InputOTP>;Anatomy
Examples
About
The slots are presentation. A single hidden <input> behind them owns the value, so paste, browser
autofill and the platform's SMS suggestion all land in the right cells, and the caret never has to
be managed by hand. It is input-otp by Guilherme Rodz, the engine shadcn ships under every style.
One hidden input drives every slot — paste, autofill and the mobile SMS suggestion all land in the right cells.
Composition
InputOTP
├── InputOTPGroup
│ └── InputOTPSlot
├── InputOTPSeparator
└── InputOTPGroup
└── InputOTPSlotPattern
pattern rejects characters as they are typed. input-otp exports REGEXP_ONLY_DIGITS,
REGEXP_ONLY_CHARS and REGEXP_ONLY_DIGITS_AND_CHARS.
Letters are rejected as you type.
Separator
InputOTPSeparator divides the groups; it is role="separator" and carries no text.
Disabled
Use disabled. The container dims with has-disabled, and the control keeps
disabled:cursor-not-allowed.
Controlled
Hold the value yourself with value and onChange.
Invalid
Put aria-invalid on the slots. The tint is scoped to not-data-[active=true]: (FOC-5), so the
slot the caret is in shows its focus tint rather than the error colour.
Four Digits
The common PIN shape — four slots, digits only.
Alphanumeric
REGEXP_ONLY_DIGITS_AND_CHARS accepts letters and numbers.
Form
Inside a Field, with a description and a submit button.
RTL
A code is read left to right in every locale, so the slots keep their order; the group's rounding
mirrors with first:rounded-s-lg / last:rounded-e-lg.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
containerClassName | string | — |
| Prop | Type | Default | Description |
|---|---|---|---|
index* | number | — |
InputOTPGroup and InputOTPSeparator add no props of their own — each accepts everything a
<div> accepts.
Accessibility
- One real
<input>holds the value and the focus; the slots are<div>s that mirror its state. That is what makes paste, autofill and the mobile code suggestion work. - The active slot is marked with
data-active, which paints the border tint the rest of the text-entry family uses on:focus(FOC-3) — there is no ring anywhere (FOC-6). The hidden input suppresses its own outline inline, so nothing paints a ring around the whole overlay either. - Each slot is 32×32, comfortably past the 24px target floor (A11Y-2).
- Give the field a label:
FieldLabelbound withhtmlFor, or anaria-label.
| Contract | States tested |
|---|---|
| Behaviour | default, active, filled, disabled, invalid |
| Accessibility | native-or-base-ui-semantics, browser-accessibility-test, invalid, disabled |
| Visual | default, active, invalid, disabled |
Do / Don't
Deviations
Upstream's file plus packages/ui/upstream/patches/input-otp.patch. Every hunk:
- FOC-1, FOC-3, FOC-6 — the active slot's
data-[active=true]:border-ringplusring-3 ring-ring/50becomesdata-[active=true]:border-ring/70: the same border tint the rest of the text-entry family uses, and no glow. - FOC-5 — the invalid tint becomes
not-data-[active=true]:aria-invalid:border-destructive, so the slot holding the caret shows its own cue; the group'shas-aria-invalidhalo rings are removed and its border tint becomesnot-focus-within:has-aria-invalid:border-destructive. - DOC-2 —
cnis imported from@vegastack/design.