Skip to content

Input OTP

A segmented field for one-time passcodes, PINs and recovery codes, with paste, SMS autofill and password-manager support.

SourceRegistry
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

  1. 1GroupA run of slots that share their borders, so a group reads as one field.
  2. 2SlotOne character. The slot being typed into takes the focus color and shows a caret.
  3. 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.json

Usage

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.

"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.

"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.

"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.

4
8
1
2
0
9
"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.

DoTwo groups of three mirror how people read and remember the code.
Don’tSeparate inputs per digit break paste, autofill and screen readers.

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 InputOTP an id for a FieldLabel, or an aria-label such 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 input token at 3:1; the active slot adds the brand focus halo.
KeyBehavior
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.

PropTypeDefault
maxLengthrequired

Number of characters — must match the number of slots.

numberNo default
value

The controlled value.

stringNo default
onChange

Called with the new value on every change.

(value: string) => voidNo default
onComplete

Called when every slot is filled.

(value: string) => voidNo default
pattern

A regular expression each character must match. Use the exported REGEXP_ONLY_DIGITS, REGEXP_ONLY_CHARS or REGEXP_ONLY_DIGITS_AND_CHARS.

stringNo default
pasteTransformer

Cleans pasted text before it's validated, e.g. removing dashes.

(pasted: string) => stringNo default
inputMode

The virtual keyboard to show. Use text for codes with letters.

string"numeric"
textAlign

Where the caret starts when the field is focused by clicking outside a slot.

"left" | "center" | "right""left"
pushPasswordManagerStrategy

Makes room for password-manager badges.

"increase-width" | "none""increase-width"
containerClassName

Classes for the outer container. className styles the hidden input.

stringNo default
disabled

Disables the field and dims every slot.

booleanfalse

InputOTPGroup

Renders a <div> that joins its slots into one bordered segment.

InputOTPSlot

Renders one character cell. Accepts all div props.

PropTypeDefault
indexrequired

The zero-based position of the character this slot displays.

numberNo default

InputOTPSeparator

Renders a <div role="separator"> with a dash between groups.