Input OTP
A segmented field for one-time passcodes, PINs and recovery codes, with paste, SMS autofill and password-manager support.
import {
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot,
} from "@/components/ui/input-otp"
export default function InputOTPDemo() {
return (
<InputOTP maxLength={6} aria-label="Verification code">
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
)
}Anatomy
- 1GroupA run of slots that share their borders, so a group reads as one field.
- 2SlotOne character. The slot being typed into takes the focus color and shows a caret.
- 3SeparatorA break between groups, as in a code printed as 123 456.
Installation
$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/input-otp.jsonUsage
import {
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot,
} from "@/components/ui/input-otp"<InputOTP maxLength={6}>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>Input OTP is built on input-otp: the slots are a picture of one real input underneath. That is why paste, SMS autofill, password managers, undo and screen readers all work — there's a single field to fill, not six.
Examples
Digits only
Pass a pattern to reject characters as they're typed. REGEXP_ONLY_DIGITS fits PINs and SMS codes, and the default inputMode="numeric" brings up the number pad on phones. The patterns are re-exported from @/components/ui/input-otp; like the component, use them in client components.
Letters and symbols are ignored as you type.
"use client"
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
REGEXP_ONLY_DIGITS,
} from "@/components/ui/input-otp"
export default function InputOTPDigitsOnly() {
return (
<Field className="w-fit">
<FieldLabel htmlFor="input-otp-pin">Card PIN</FieldLabel>
<InputOTP
id="input-otp-pin"
maxLength={4}
pattern={REGEXP_ONLY_DIGITS}
inputMode="numeric"
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
</InputOTPGroup>
</InputOTP>
<FieldDescription>
Letters and symbols are ignored as you type.
</FieldDescription>
</Field>
)
}Letters and digits
For recovery codes, allow letters with REGEXP_ONLY_DIGITS_AND_CHARS and switch inputMode to text so mobile keyboards can type them. A pasteTransformer cleans pasted values — here it strips dashes and uppercases the code.
Paste a code like 7F2K-9QXA — dashes are removed.
"use client"
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"
import {
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot,
REGEXP_ONLY_DIGITS_AND_CHARS,
} from "@/components/ui/input-otp"
export default function InputOTPAlphanumeric() {
return (
<Field className="w-fit">
<FieldLabel htmlFor="input-otp-recovery">Recovery code</FieldLabel>
<InputOTP
id="input-otp-recovery"
maxLength={8}
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
inputMode="text"
pasteTransformer={(pasted) => pasted.replaceAll("-", "").toUpperCase()}
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
<InputOTPSlot index={6} />
<InputOTPSlot index={7} />
</InputOTPGroup>
</InputOTP>
<FieldDescription>
Paste a code like 7F2K-9QXA — dashes are removed.
</FieldDescription>
</Field>
)
}Controlled with auto-submit
Control the value with value and onChange, and verify with onComplete, which fires once every slot is filled. Disable the field while verifying, keep what people typed if it fails, and say where the code was sent.
We sent it to ada@prfct.dev.
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
REGEXP_ONLY_DIGITS,
} from "@/components/ui/input-otp"
export default function InputOTPControlled() {
const [code, setCode] = React.useState("")
const [status, setStatus] = React.useState<"idle" | "verifying" | "verified">(
"idle"
)
// Stands in for the request that checks the code; cleared on unmount.
React.useEffect(() => {
if (status !== "verifying") return
const timer = setTimeout(() => setStatus("verified"), 1200)
return () => clearTimeout(timer)
}, [status])
return (
<Field className="w-fit items-center">
<FieldLabel htmlFor="input-otp-controlled">
Enter the 6-digit code
</FieldLabel>
<InputOTP
id="input-otp-controlled"
maxLength={6}
pattern={REGEXP_ONLY_DIGITS}
value={code}
onChange={(value) => {
setCode(value)
setStatus("idle")
}}
onComplete={() => setStatus("verifying")}
disabled={status === "verifying"}
>
<InputOTPGroup>
{Array.from({ length: 6 }, (_, index) => (
<InputOTPSlot key={index} index={index} />
))}
</InputOTPGroup>
</InputOTP>
<FieldDescription aria-live="polite" className="text-center">
{status === "verifying" && "Verifying…"}
{status === "verified" && "Verified. You’re signed in."}
{status === "idle" && "We sent it to ada@prfct.dev."}
</FieldDescription>
<Button
variant="link"
size="sm"
disabled={status === "verifying"}
onClick={() => {
setCode("")
setStatus("idle")
}}
>
Resend code
</Button>
</Field>
)
}Invalid
Set aria-invalid on InputOTP and every slot turns red — there's no need to mark slots one by one. Explain the problem in a FieldError linked with aria-describedby.
"use client"
import * as React from "react"
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp"
export default function InputOTPInvalid() {
const [code, setCode] = React.useState("481209")
return (
<Field data-invalid className="w-fit">
<FieldLabel htmlFor="input-otp-invalid">Verification code</FieldLabel>
<InputOTP
id="input-otp-invalid"
maxLength={6}
value={code}
onChange={setCode}
aria-invalid
aria-describedby="input-otp-invalid-error"
>
<InputOTPGroup>
{Array.from({ length: 6 }, (_, index) => (
<InputOTPSlot key={index} index={index} />
))}
</InputOTPGroup>
</InputOTP>
<FieldError id="input-otp-invalid-error">
That code has expired. Request a new one.
</FieldError>
</Field>
)
}Guidelines
When to use
- Fixed-length codes people copy from somewhere else: two-factor codes, email verification codes, PINs, recovery codes.
When not to use
- Passwords or anything of variable length — use an Input with
type="password". - Codes longer than about 8 characters — a single input with a clear format hint is easier to fill.
Chunk long codes
Short-term memory holds three or four characters at a time. Split six-digit codes into two groups of three and eight-character codes into two groups of four with an InputOTPSeparator — and match the grouping of the email or SMS that delivers the code.
Forgive and recover
Submit automatically on completion, but never lock people in: keep their input when a code fails, let them edit any slot, and offer Resend code with a visible cooldown rather than a dead end.
Accessibility
- One field. Screen readers announce a single text field and read back the full value; there is one tab stop, not one per slot.
- Label it. Give
InputOTPanidfor aFieldLabel, or anaria-labelsuch as Verification code. - Autofill.
autoComplete="one-time-code"is set by default, so iOS and Android offer codes from SMS and email. - Password managers. With
pushPasswordManagerStrategy="increase-width"(the default), the input grows invisibly so manager badges don't cover the last slot. - Contrast. Slot borders use the
inputtoken at 3:1; the active slot adds the brand focus halo.
| Key | Behavior |
|---|---|
Tab | Moves focus to the field. The caret lands in the first empty slot. |
0–9 / A–Z | Fills the active slot and advances. Characters that don’t match pattern are ignored. |
Backspace | Deletes the previous character. |
←→ | Moves the caret between slots. |
CtrlV⌘V | Pastes a code and distributes it across the slots. |
API reference
InputOTP
Renders the container and one hidden <input>. Accepts all native input props plus the options of input-otp.
InputOTPGroup
Renders a <div> that joins its slots into one bordered segment.
InputOTPSlot
Renders one character cell. Accepts all div props.
InputOTPSeparator
Renders a <div role="separator"> with a dash between groups.