Skip to content
Component installs need the registry setup
VegaStack Design

Input OTP

A one-time-password field with per-character slots — copy/paste aware, patterned, separable, and driven by one hidden input.

Status
stable
Since
0.10.0
Accessibility pattern
single native input behind the slots

Last updated

Install

Configure registry credentials first using the Quickstart. Before copying this item or any transitive registry dependency, verify the signed manifest and save the exact bytes for Input OTP before installation. Retain the digest printed by this command.

pnpm exec vegastack-design verify input-otp

Install with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.

pnpm dlx shadcn@4.21.0 add @vegastack/input-otp

Run the exact offline --post-write command printed by the preflight, using its saved item path and independently retained --expected-integrity digest. Use the saved preflight for every transitive registry dependency too; stop on any mismatch before using the components.

It 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

InputOTP — data-slot="input-otp"
InputOTPGroup — data-slot="input-otp-group"
InputOTPSlot — data-slot="input-otp-slot"
InputOTPSeparator — data-slot="input-otp-separator"

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.

1
2
3
4
5
6

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
    └── InputOTPSlot

Pattern

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.

1
2
3
4
5
6

Controlled

Hold the value yourself with value and onChange.

Enter your one-time password.

Invalid

Put aria-invalid on the slots. The destructive border holds in every state, the active slot included (FOC-14): focus never replaces it with the darker focus border.

0
0
0
0
0
0

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.

We sent a six-digit code to your email.

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.

1
2
3
4
5
6
1
2
3
4
5
6

API Reference

PropTypeDefaultDescription
containerClassNamestring—
PropTypeDefaultDescription
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 darkens its border subtly (border-ring/50, easing in over 150ms) — the same cue the rest of the text-entry family shows on focus (FOC-3, and FOC-14's text-entry exception, MK 2026-09-27). No slot changes its background, an invalid slot keeps its destructive border while active, and 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: FieldLabel bound with htmlFor, or an aria-label.
ContractStates tested
Behaviourdefault, active, filled, disabled, invalid
Accessibilitynative-or-base-ui-semantics, browser-accessibility-test, invalid, disabled
Visualdefault, active, invalid, disabled

Do / Don't

Do
Use maxLength and a pattern that match the code you actually send, so a wrong character is rejected before submit.
Don't
Render one Input per character — paste, autofill and backspace all break.

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-ring plus ring-3 ring-ring/50 is removed: no ring and no glow.
  • FOC-14 — the slot's invalid border is aria-invalid:border-destructive, held in every state, the active slot included; the group's has-aria-invalid halo rings are removed and its invalid border is has-aria-invalid:border-destructive.
  • FOC-14 text-entry exception (MK 2026-09-27) — the active slot takes data-[active=true]:not-aria-invalid:border-ring/50, replacing its old bg-accent/50 fill, with transition-[color,background-color,border-color] duration-150 ease-out: a subtle darker border that eases in, and no fill. The not-aria-invalid guard keeps the destructive border.
  • DOC-2 — cn is imported from @vegastack/design.

On this page