Enter the 6-digit code we sent to your device.
Installation
npx shadcn@latest add https://ui.srb.codes/r/input-otp.json
Usage
"use client";
import * as React from "react";
import {
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot
} from "@/components/input-otp";
export function VerificationField() {
const [value, setValue] = React.useState("");
return (
<InputOTP maxLength={6} value={value} onChange={setValue}>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
);
}The component is a thin wrapper around input-otp, so anything that library accepts on OTPInput — pattern, inputMode, autoFocus, onComplete, controlled and uncontrolled modes — works the same way here. The slot count is driven by maxLength and how many <InputOTPSlot index={…} /> elements you render.
Patterns
Four-digit code, no separator
Drop the separator and lower the maxLength for shorter PINs. The active-ring indicator still slides between adjacent slots because the layoutId is shared inside a group.
<InputOTP maxLength={4} value={value} onChange={setValue}>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
</InputOTPGroup>
</InputOTP>Auto-submit on completion
input-otp calls onComplete once the value reaches maxLength. Pair it with useState and a submit effect to fire your verification call without a separate button.
<InputOTP
maxLength={6}
value={value}
onChange={setValue}
onComplete={(code) => verifyOtp(code)}
>
<InputOTPGroup>
{Array.from({ length: 6 }).map((_, i) => (
<InputOTPSlot key={i} index={i} />
))}
</InputOTPGroup>
</InputOTP>Validation feedback
Set aria-invalid on the root once verification fails. The slots pick up a destructive border, and the active slot's ring switches to the destructive color so the user can see exactly where the cursor is sitting when they go to retry.
<InputOTP
maxLength={6}
value={value}
onChange={setValue}
aria-invalid={status === "invalid"}
>
{/* …slots… */}
</InputOTP>Numeric-only on mobile
Pass inputMode="numeric" and pattern={REGEXP_ONLY_DIGITS} from input-otp to keep mobile keyboards on the digit pane and reject pasted letters.
import { REGEXP_ONLY_DIGITS } from "input-otp";
<InputOTP
maxLength={6}
inputMode="numeric"
pattern={REGEXP_ONLY_DIGITS}
value={value}
onChange={setValue}
>
{/* …slots… */}
</InputOTP>;Anatomy
<InputOTP>
<InputOTPGroup>
<InputOTPSlot />
<InputOTPSlot />
<InputOTPSlot />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot />
<InputOTPSlot />
<InputOTPSlot />
</InputOTPGroup>
</InputOTP>| Part | Role |
|---|---|
InputOTP | Root. Wraps input-otp's <OTPInput> and forwards every prop. Owns the hidden text input that captures keypresses and clipboard pastes. |
InputOTPGroup | Visual cluster of slots. Slots inside the same group share an active-ring layoutId, so the ring slides between adjacent slots. |
InputOTPSlot | One digit slot. Renders the animated character, the blinking caret on the empty active slot, and the active-ring indicator. |
InputOTPSeparator | Decorative dash between groups. Pure presentation — aria-hidden. |
Props
InputOTP
Forwards every prop of OTPInput from input-otp. The most useful ones:
| Prop | Type | Default | Description |
|---|---|---|---|
maxLength | number | — | Total number of digits. Must match the count of <InputOTPSlot> you render. |
value | string | — | Controlled value. Omit to run uncontrolled (use defaultValue). |
onChange | (value: string) => void | — | Fires on every keystroke or paste. |
onComplete | (value: string) => void | — | Fires once value.length === maxLength. Useful for auto-submit. |
pattern | string | — | Regex string applied to each character. Pair with REGEXP_ONLY_DIGITS for numeric-only. |
inputMode | "numeric" | "text" | … | — | Hints the on-screen keyboard layout on mobile. |
disabled | boolean | false | Greys the slots out (opacity-50) and blocks input. |
containerClassName | string | — | Forwarded to the slot container — the flex wrapper that holds the groups and separators. |
className | string | — | Forwarded to the hidden text input. |
aria-invalid | boolean | — | When true, slots show the destructive border and the active ring switches to the destructive color. |
InputOTPSlot
| Prop | Type | Default | Description |
|---|---|---|---|
index | number | — | Required. Position of this slot inside the OTP, zero-indexed. Slot 0 is the leftmost digit. |
className | string | — | Forwarded to the slot's outer <motion.div>. Useful for sizing — the default is h-10 w-9. |
All other props of motion.div (initial/animate/transition overrides, refs, native attributes) are forwarded.
InputOTPGroup, InputOTPSeparator
Both forward every native <div> prop. InputOTPSeparator is aria-hidden by default.
How the animations work
Three things animate, all driven by motion:
- The digit itself — when a character lands in a slot,
<InputOTPAnimatedNumber>mounts a<motion.span>keyed on the new value.AnimatePresenceruns the previous span's exit (opacity 1 → 0) and the new span's enter (opacity 0.2 → 1,y: 20 → 0) over 90 ms, so the digit feels like it falls into place. - The active-ring indicator — a
<motion.div>withlayoutId="indicator"mounts inside whichever slot is active. As focus moves between slots in the same group, motion's shared-layout magic interpolates the ring's position over 120 ms, so it slides instead of teleporting. When multiple slots are active simultaneously (a paste selection range), thelayoutIdswitches to a per-index value so each ring stays put rather than fighting for the samelayoutId. - The caret — when the active slot is empty,
<FakeCaret>renders a 1-px-wide bar that blinks via theanimate-caret-blinkkeyframes fromtw-animate-css. It's wrapped inmotion-safe:so users withprefers-reduced-motiondon't see it pulse.
The whole component sits inside a <MotionConfig reducedMotion="user">, so the digit fade and ring slide also respect the user's motion preference. With reduced motion on, transitions still happen — they just snap rather than ease.
Theming
The component uses the registry's standard tokens, so it picks up your existing theme without extra setup:
| Token | Where it appears |
|---|---|
--background | inherited via the slot's neutral surface (parent context) |
--muted | slot fill |
--muted-foreground | the blinking caret |
--foreground | the digit color |
--border | the separator dash |
--input | the slot border in its resting state |
--ring | the active-slot ring |
--destructive | the slot border + active ring when aria-invalid="true" |
To change the slot dimensions or radius, override className on <InputOTPSlot> — defaults are h-10 w-9 rounded-[10px]. The active-ring indicator uses rounded-[inherit], so it follows whatever radius you set.
Accessibility
- The root
<OTPInput>frominput-otpis a real<input>under the hood, so it works with screen readers, password managers, and SMS autofill (autoComplete="one-time-code"). - The blinking caret only appears in the empty active slot, and is
aria-hiddenbecause the underlying input already announces position changes. MotionConfig reducedMotion="user"ensures the digit fade, ring slide, and caret blink all respectprefers-reduced-motion.- Set
aria-invalidon the root to surface validation errors visually and to assistive tech in one shot.