shadcn ui : Input OTP primitive
The InputOTP family wraps the input-otp library by guilhermerodz and renders a one-time-password input as a row of fixed-width slots that mimic individual character boxes while remaining a single accessible text input under the hood. Every claim in this skill traces to the canonical source at apps/v4/registry/new-york-v4/ui/input-otp.tsx in shadcn-ui/ui, the official docs at https://ui.shadcn.com/docs/components/radix/input-otp, and the upstream library at https://github.com/guilhermerodz/input-otp.
Quick Reference
| Primitive | Wraps | Role |
|---|---|---|
InputOTP |
OTPInput from input-otp |
Root container, owns maxLength, value, onChange, pattern, onComplete, autoComplete, disabled |
InputOTPGroup |
plain div |
Visual group of consecutive slots ; combine multiple groups with InputOTPSeparator between them |
InputOTPSlot |
plain div reading from OTPInputContext |
One visible character box, requires index: number matching its position 0 to maxLength - 1 |
InputOTPSeparator |
plain div with role="separator" and a MinusIcon |
Visual divider between two InputOTPGroup instances |
Install : pnpm dlx shadcn@latest add input-otp (CLI copies components/ui/input-otp.tsx and installs the input-otp peer dependency).
When to use this skill
Use InputOTP for any flow where the user must enter a short, fixed-length code character-by-character with visible per-character boxes :
- Email verification codes (typically 6 digits)
- SMS-delivered second-factor codes (typically 6 digits, SMS autofill expected)
- Authenticator-app TOTP codes (6 digits)
- Backup-code entry (often 8 to 10 alphanumeric)
- PIN entry on payment or unlock flows (typically 4 to 6 digits)
Do NOT use InputOTP for free-text fields, passwords, or any input where the length is unknown ; use Input from shadcn-syntax-form or the underlying <input> element instead.
The four primitives and how they compose
"use client"
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
InputOTPSeparator,
} from "@/components/ui/input-otp"
import { REGEXP_ONLY_DIGITS } from "input-otp"
export function SixDigitCode() {
return (
<InputOTP maxLength={6} pattern={REGEXP_ONLY_DIGITS}>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
)
}
Three rules govern composition :
- The number of
InputOTPSlotinstances rendered insideInputOTPMUST equalmaxLength. If you render fewer slots, characters typed beyond the last visible slot still enter the underlying input but the user cannot see them. If you render more, the extra slots readundefinedfrom context and render empty forever. - Each
InputOTPSlotMUST receive a uniqueindexprop matching its position.index={0}is the leftmost slot,index={maxLength - 1}is the rightmost. The slot uses this index to read{ char, hasFakeCaret, isActive }fromOTPInputContext. InputOTPSeparatorbelongs BETWEEN twoInputOTPGroupelements, not inside one. It is a sibling of groups, not a sibling of slots.
The 'use client' directive
The shadcn input-otp.tsx file begins with "use client" because InputOTPSlot calls React.useContext(OTPInputContext) and OTPInput itself uses hooks for caret animation. In a Next.js App Router project the file is already a client component ; any consuming page or layout that imports it stays a Server Component, which is fine. If you re-export InputOTP from a barrel file in a Server Component, the directive on the original file still applies. Do NOT remove the directive when customizing the file.
maxLength : the slot contract
maxLength is the single most important prop. It tells the underlying OTPInput how many characters the user may type and it implicitly defines how many InputOTPSlot instances you MUST render. Common values :
4: short PIN6: standard email or SMS verification code8: backup code10: long backup code or recovery code
If you omit maxLength, OTPInput renders nothing usable and the user sees an empty box with no slots ; this is the single most common cause of "the OTP component is broken" bug reports.
pattern : restricting allowed characters
pattern accepts a regex source string (not a JS RegExp object) that defines which characters the user may type. The library exports three pre-built constants you should prefer over hand-written patterns :
| Constant | Source pattern | Use for |
|---|---|---|
REGEXP_ONLY_DIGITS |
^\d+$ |
Numeric codes (SMS, email, TOTP, PIN) |
REGEXP_ONLY_DIGITS_AND_CHARS |
^[a-zA-Z0-9]+$ |
Alphanumeric backup codes, license keys |
REGEXP_ONLY_CHARS |
^[a-zA-Z]+$ |
Letter-only codes (rare) |
Import from the library : import { REGEXP_ONLY_DIGITS } from "input-otp". The pattern is applied PER KEYSTROKE : a character that does not match is silently rejected. The pattern also applies to pasted strings : a paste containing any rejected character is rejected as a whole.
If you must hand-write a pattern, supply the regex source as a string :
<InputOTP maxLength={6} pattern="^[0-9]+$">...</InputOTP>
Do NOT pass a RegExp object literal. pattern={/^[0-9]+$/} will not behave as you expect because the library re-constructs the regex from a string internally.
value and onChange : controlled pattern
The canonical controlled pattern :
const [value, setValue] = React.useState("")
return (
<InputOTP maxLength={6} value={value}
...
</InputOTP>
)
value is always a string with length between 0 and maxLength. onChange fires with the new string on every keystroke. The component is also valid uncontrolled (drop value and onChange) and exposes the value through the underlying input's standard form submission.
onComplete : reacting to a finished code
onComplete fires exactly once when the user types or pastes the final character that brings the value's length to maxLength. Use it to auto-submit the form or fire the verification request without requiring a separate submit click :
<InputOTP
maxLength={6}
value={value}
=> verifyCode(code)}
>
...
</InputOTP>
onComplete receives the full code string. It does NOT fire on every value change ; only on the transition to value.length === maxLength.
autoComplete="one-time-code" : SMS autofill
On iOS Safari and Android Chrome, setting autoComplete="one-time-code" on an input that the system detects as an OTP field unlocks two behaviours :
- The keyboard offers a suggestion chip with the code parsed from a recently received SMS.
- The browser may auto-fill the field without user interaction in some flows.
OTPInput forwards autoComplete to the underlying single hidden <input>. Without this prop, neither autofill path triggers and your users will type the code manually even when their phone received it 200 milliseconds ago.
<InputOTP maxLength={6} autoComplete="one-time-code">
...
</InputOTP>
Paste : a full code in one operation
input-otp rewrites the underlying paste handler to accept a clipboard string of up to maxLength characters and distribute it across slots in one operation. The user CAN paste a full 6-digit code from their clipboard onto the first slot (or any slot) and every slot fills correctly. The pattern constraint applies to the pasted string as a whole : a paste that contains any rejected character is rejected entirely.
No additional configuration is required. Paste works out of the box on desktop and mobile.
data-active and aria-invalid : styling hooks
Two attributes drive the default Tailwind classes on InputOTPSlot :
data-active="true": applied to the slot that is currently focused. Drives the ring and z-index lift in the default classes.aria-invalid="true": when you place this onInputOTP(or the underlying input via custom render), the destructive border and ring colours apply automatically.
<InputOTP maxLength={6} aria-invalid={hasError}>
...
</InputOTP>
The default styles use aria-invalid:border-destructive, data-[active=true]:border-ring, and data-[active=true]:ring-ring/50 ; customize by passing className to individual InputOTPSlot instances.
disabled and has-disabled
disabled on InputOTP forwards to the underlying input and triggers two style hooks :
disabled:cursor-not-allowedon the input itselfhas-disabled:opacity-50on the container (thehas-variant requires Tailwind v3.4 or v4)
<InputOTP maxLength={6} disabled={isVerifying}>
...
</InputOTP>
Form integration : use Controller, not register
InputOTP is a controlled component that exposes value and onChange, not a raw <input>. The react-hook-form register() API cannot drive it correctly because register expects a native input ref and target.value flow. ALWAYS use the Controller component to bridge :
import { Controller, useForm } from "react-hook-form"
const { control, handleSubmit } = useForm<{ code: string }>({
defaultValues: { code: "" },
})
return (
<form
<Controller
control={control}
name="code"
rules={{ minLength: 6, maxLength: 6 }}
render={({ field }) => (
<InputOTP
maxLength={6}
value={field.value}
>
<InputOTPGroup>
{[0, 1, 2, 3, 4, 5].map((i) => (
<InputOTPSlot key={i} index={i} />
))}
</InputOTPGroup>
</InputOTP>
)}
/>
</form>
)
When wrapping InputOTP with the higher-level FormField from shadcn-syntax-form, the same Controller wiring happens for you ; you pass render={({ field }) => <InputOTP {...field}>...</InputOTP>} and FormField injects Controller behind the scenes.
What this skill does NOT cover
- The
FormField+FormItem+FormControl+FormMessagecomposition that adds label and error wiring aroundInputOTP. Seeshadcn-syntax-form. - End-to-end form validation with zod resolvers, async server validation, and submit handlers. See
shadcn-impl-form-validation. - The full underlying
input-otprenderprop (used only when fully replacing the shadcn slot rendering). The shadcn registry already wrapsOTPInputwithdata-slotmarkup, so therenderprop is not exposed at theInputOTPlayer.
References
- Primitive signatures :
references/methods.md - Working examples (6-digit numeric, 4-digit PIN with separator, alphanumeric, controlled with onComplete, Controller integration, full MFA verify flow) :
references/examples.md - Anti-patterns and how to fix them :
references/anti-patterns.md
Companion skills
shadcn-syntax-form: the Form composition layer (FormField, FormItem, FormLabel, FormControl, FormMessage) that wrapsInputOTPwith react-hook-form context and label or error wiring.shadcn-impl-form-validation: end-to-end recipes for form validation with zod, submit handlers, server actions, and async verification flows that consume the code fromInputOTP.