Every example on this page renders the same component with the same state. Only the markup changes. Flip to the Code tab on any of them — that source is read straight off disk, so it is exactly what is running above it.
A complete verification flow#
The one to copy if you are building the real thing: label, hint, auto-submit on completion, a pending state, an error that clears itself, focus recovery after a failure, and a resend cooldown. Type 424242 to succeed.
Check your phone
We sent a 6-digit code to •••• 4417.
import * as React from 'react'
import { OTPInput, REGEXP_ONLY_DIGITS } from 'input-otp'
import { cn } from '@/lib/utils'
import { Slot } from '@/components/ui/otp-slot'
const CORRECT_CODE = '424242'
const RESEND_SECONDS = 30
/**
* The whole flow in one component: label, hint, auto-submit on completion,
* pending state, error handling with focus recovery, and a resend cooldown.
*/
export function VerifyCardDemo() {
const [value, setValue] = React.useState('')
const [status, setStatus] = React.useState<'idle' | 'pending' | 'success'>(
'idle',
)
const [error, setError] = React.useState<string | null>(null)
const [cooldown, setCooldown] = React.useState(0)
const inputRef = React.useRef<HTMLInputElement>(null)
React.useEffect(() => {
if (cooldown === 0) return
const timer = setTimeout(() => setCooldown(s => s - 1), 1000)
return () => clearTimeout(timer)
}, [cooldown])
async function verify(code: string) {
setStatus('pending')
await new Promise(resolve => setTimeout(resolve, 800))
if (code === CORRECT_CODE) {
setStatus('success')
return
}
setStatus('idle')
setError('That code is incorrect. Try 424242.')
setValue('')
inputRef.current?.focus()
}
return (
<div className="w-full max-w-sm rounded-xl border border-border/70 bg-background/60 p-6 shadow-[0_20px_60px_-30px_rgb(0_0_0/0.8)]">
<h3 className="text-base font-semibold tracking-tight text-foreground">
Check your phone
</h3>
<p id="verify-hint" className="mt-1 text-sm text-muted-foreground">
We sent a 6-digit code to{' '}
<span className="text-foreground">•••• 4417</span>.
</p>
<form
className="mt-5 flex flex-col items-center gap-4"
onSubmit={e => {
e.preventDefault()
verify(value)
}}
>
<label htmlFor="verify-code" className="sr-only">
Verification code
</label>
<OTPInput
ref={inputRef}
id="verify-code"
name="code"
value={value}
onChange={next => {
setValue(next)
if (error) setError(null)
}}
onComplete={verify}
maxLength={6}
pattern={REGEXP_ONLY_DIGITS}
disabled={status !== 'idle'}
aria-describedby={error ? 'verify-hint verify-error' : 'verify-hint'}
aria-invalid={error !== null}
containerClassName={cn(
'group flex items-center has-[:disabled]:opacity-50',
error && 'motion-safe:animate-[otp-shake_450ms_ease-in-out]',
)}
render={({ slots }) => (
<div className="flex">
{slots.map((slot, idx) => (
<Slot
key={idx}
{...slot}
className={cn(
'h-12 w-10 text-lg',
error && 'border-destructive/60',
)}
/>
))}
</div>
)}
/>
<p aria-live="polite" className="min-h-5 text-center text-xs leading-5">
{error && (
<span id="verify-error" role="alert" className="text-red-400">
{error}
</span>
)}
{status === 'pending' && (
<span className="text-muted-foreground">Verifying…</span>
)}
{status === 'success' && (
<span className="text-emerald-400">Verified. Signing you in…</span>
)}
</p>
<button
type="button"
disabled={cooldown > 0 || status !== 'idle'}
onClick={() => setCooldown(RESEND_SECONDS)}
className="text-xs text-muted-foreground underline decoration-border underline-offset-4 transition-colors duration-150 hover:text-foreground disabled:no-underline disabled:opacity-50"
>
{cooldown > 0 ? `Resend in ${cooldown}s` : 'Resend code'}
</button>
</form>
</div>
)
}Layouts#
Shared border#
The default look: one continuous box, divided.
import { OTPInput } from 'input-otp'
import { Slot } from '@/components/ui/otp-slot'
export function BasicDemo() {
return (
<OTPInput
maxLength={6}
containerClassName="group flex items-center has-[:disabled]:opacity-40"
render={({ slots }) => (
<div className="flex">
{slots.map((slot, idx) => (
<Slot key={idx} {...slot} />
))}
</div>
)}
/>
)
}Two groups with a dash#
Stripe's arrangement, and the reason many people recognise this pattern. slots is an array, so this is slice and a decorative divider.
import { OTPInput } from 'input-otp'
import { FakeDash, Slot } from '@/components/ui/otp-slot'
export function GroupsDemo() {
return (
<OTPInput
maxLength={6}
containerClassName="group flex items-center has-[:disabled]:opacity-40"
render={({ slots }) => (
<>
<div className="flex">
{slots.slice(0, 3).map((slot, idx) => (
<Slot key={idx} {...slot} />
))}
</div>
<FakeDash />
<div className="flex">
{slots.slice(3).map((slot, idx) => (
<Slot key={idx} {...slot} />
))}
</div>
</>
)}
/>
)
}Separated boxes#
Four rounded cells with gaps — the PIN shape.
import { OTPInput, REGEXP_ONLY_DIGITS } from 'input-otp'
import { cn } from '@/lib/utils'
import { FakeCaret } from '@/components/ui/otp-slot'
/** A 4-digit PIN with gaps instead of a shared border. */
export function PinDemo() {
return (
<OTPInput
maxLength={4}
pattern={REGEXP_ONLY_DIGITS}
containerClassName="group flex items-center"
render={({ slots }) => (
<div className="flex gap-3">
{slots.map((slot, idx) => (
<div
key={idx}
className={cn(
'relative flex h-14 w-14 items-center justify-center rounded-xl',
'border border-border bg-background/40 text-2xl font-medium tabular-nums',
'transition-all duration-200',
slot.isActive &&
'border-foreground/50 bg-foreground/[0.04] shadow-[0_0_0_3px_hsl(0_0%_100%/0.06)]',
)}
>
{slot.char}
{slot.hasFakeCaret && <FakeCaret />}
</div>
))}
</div>
)}
/>
)
}Underlined#
No boxes at all: a rule under each character that thickens when active.
import { OTPInput } from 'input-otp'
import { cn } from '@/lib/utils'
/** No boxes: a rule under each character, thicker where the caret is. */
export function UnderlinedDemo() {
return (
<OTPInput
maxLength={6}
containerClassName="group flex items-center"
render={({ slots }) => (
<div className="flex gap-3">
{slots.map((slot, idx) => (
<div
key={idx}
className="relative flex h-12 w-9 items-end justify-center pb-2"
>
<span className="text-xl font-medium tabular-nums text-foreground">
{slot.char}
</span>
{slot.isActive && slot.char === null && (
<span className="absolute bottom-3 h-5 w-px bg-foreground motion-safe:animate-caret-blink" />
)}
<span
className={cn(
'absolute inset-x-0 bottom-0 h-px transition-all duration-200',
slot.isActive
? 'h-0.5 bg-foreground'
: slot.char !== null
? 'bg-foreground/40'
: 'bg-border',
)}
/>
</div>
))}
</div>
)}
/>
)
}Keycaps#
Tactile cells with the character dropping in as it lands. The animation is keyed on slot.char and sits behind motion-safe:.
import { OTPInput } from 'input-otp'
import { cn } from '@/lib/utils'
/** Tactile keycaps, with the character sliding in as it lands. */
export function KeycapsDemo() {
return (
<OTPInput
maxLength={6}
containerClassName="group flex items-center"
render={({ slots }) => (
<div className="flex gap-2">
{slots.map((slot, idx) => (
<div
key={idx}
className={cn(
'relative flex h-16 w-12 items-center justify-center overflow-hidden rounded-lg',
'border border-border bg-gradient-to-b from-white/[0.06] to-transparent',
'shadow-[0_2px_6px_-2px_rgb(0_0_0/0.6),inset_0_1px_0_hsl(0_0%_100%/0.07)]',
'transition-transform duration-150',
slot.isActive && 'border-foreground/40 translate-y-px',
)}
>
<span
key={slot.char}
className={cn(
'text-2xl font-semibold tabular-nums text-foreground',
slot.char !== null &&
'motion-safe:animate-[keycap-drop_220ms_cubic-bezier(0.22,1,0.36,1)]',
)}
>
{slot.char}
</span>
{slot.hasFakeCaret && (
<span className="absolute bottom-2 h-0.5 w-5 rounded-full bg-foreground motion-safe:animate-caret-blink" />
)}
</div>
))}
</div>
)}
/>
)
}Full width#
Slots that flex instead of overflowing. Worth doing — six fixed-width slots will break a 320px viewport at 200% zoom.
// Slots that shrink instead of overflowing a narrow viewport.
<OTPInput
maxLength={6}
containerClassName="group flex w-full max-w-xs items-center"
render={({ slots }) => (
<div className="flex w-full">
{slots.map((slot, idx) => (
<Slot key={idx} {...slot} className="h-12 w-full min-w-0 flex-1 text-base" />
))}
</div>
)}
/>Behaviour#
Placeholder#
import { OTPInput } from 'input-otp'
import { Slot } from '@/components/ui/otp-slot'
export function PlaceholderDemo() {
return (
<OTPInput
maxLength={6}
placeholder="000000"
containerClassName="group flex items-center"
render={({ slots }) => (
<div className="flex">
{slots.map((slot, idx) => (
<Slot key={idx} {...slot} />
))}
</div>
)}
/>
)
}Masked, with a reveal toggle#
Masking is a rendering decision — the value is untouched, so revealing it is one boolean.
import * as React from 'react'
import { OTPInput } from 'input-otp'
import { cn } from '@/lib/utils'
import { FakeCaret } from '@/components/ui/otp-slot'
/**
* Masking is a rendering concern: the value stays intact, the slot just draws a
* dot instead of the character. A reveal toggle is one boolean away.
*/
export function MaskedDemo() {
const [revealed, setRevealed] = React.useState(false)
return (
<div className="flex flex-col items-center gap-5">
<OTPInput
maxLength={6}
containerClassName="group flex items-center"
render={({ slots }) => (
<div className="flex">
{slots.map((slot, idx) => (
<div
key={idx}
className={cn(
'relative flex h-14 w-12 items-center justify-center',
'border-y border-r border-border bg-background/40 text-[1.375rem] font-medium tabular-nums text-foreground',
'first:rounded-l-md first:border-l last:rounded-r-md',
'outline outline-0 outline-foreground/80 transition-all duration-200',
slot.isActive && 'z-10 outline-2',
)}
>
{slot.char !== null && (
<span
className={cn(!revealed && 'text-[1.75rem] leading-none')}
>
{revealed ? slot.char : '•'}
</span>
)}
{slot.hasFakeCaret && <FakeCaret />}
</div>
))}
</div>
)}
/>
<label className="flex cursor-pointer items-center gap-2 text-sm text-muted-foreground">
<input
type="checkbox"
checked={revealed}
onChange={e => setRevealed(e.target.checked)}
className="h-3.5 w-3.5 accent-foreground"
/>
Reveal code
</label>
</div>
)
}Controlled#
""import * as React from 'react'
import { OTPInput } from 'input-otp'
import { Slot } from '@/components/ui/otp-slot'
export function ControlledDemo() {
const [value, setValue] = React.useState('')
return (
<div className="flex flex-col items-center gap-5">
<OTPInput
value={value}
onChange={setValue}
maxLength={6}
containerClassName="group flex items-center"
render={({ slots }) => (
<div className="flex">
{slots.map((slot, idx) => (
<Slot key={idx} {...slot} />
))}
</div>
)}
/>
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<span>
value:{' '}
<code className="font-mono text-foreground">
{value === '' ? '""' : `"${value}"`}
</code>
</span>
<button
type="button"
onClick={() => setValue('')}
className="rounded-md border border-border px-2 py-1 text-xs transition-colors duration-150 hover:bg-foreground/5 hover:text-foreground"
>
Clear
</button>
<button
type="button"
onClick={() => setValue('123456')}
className="rounded-md border border-border px-2 py-1 text-xs transition-colors duration-150 hover:bg-foreground/5 hover:text-foreground"
>
Fill
</button>
</div>
</div>
)
}Auto-submit on completion#
import * as React from 'react'
import { OTPInput } from 'input-otp'
import { Slot } from '@/components/ui/otp-slot'
export function AutoSubmitDemo() {
const formRef = React.useRef<HTMLFormElement>(null)
const [status, setStatus] = React.useState<'idle' | 'verifying' | 'done'>(
'idle',
)
return (
<form
ref={formRef}
onSubmit={async e => {
e.preventDefault()
setStatus('verifying')
await new Promise(resolve => setTimeout(resolve, 900))
setStatus('done')
}}
className="flex flex-col items-center gap-5"
>
<OTPInput
name="code"
maxLength={6}
autoFocus={false}
disabled={status !== 'idle'}
// Fires exactly once, on the transition into a full value — including
// when the value arrives all at once from a paste or an SMS autofill.
onComplete={() => formRef.current?.requestSubmit()}
containerClassName="group flex items-center has-[:disabled]:opacity-50"
render={({ slots }) => (
<div className="flex">
{slots.map((slot, idx) => (
<Slot key={idx} {...slot} />
))}
</div>
)}
/>
<p className="h-5 text-sm text-muted-foreground" aria-live="polite">
{status === 'idle' && 'Type six characters — no submit button needed.'}
{status === 'verifying' && 'Verifying…'}
{status === 'done' && (
<span className="text-emerald-400">Submitted.</span>
)}
</p>
{status === 'done' && (
<button
type="button"
onClick={() => setStatus('idle')}
className="rounded-md border border-border px-2.5 py-1 text-xs text-muted-foreground transition-colors duration-150 hover:text-foreground"
>
Reset
</button>
)}
</form>
)
}Invalid state#
placeholder
import * as React from 'react'
import { OTPInput } from 'input-otp'
import { cn } from '@/lib/utils'
import { FakeCaret } from '@/components/ui/otp-slot'
const CORRECT_CODE = '123456'
export function InvalidDemo() {
const [value, setValue] = React.useState('')
const [error, setError] = React.useState<string | null>(null)
return (
<div className="flex flex-col items-center gap-4">
<OTPInput
value={value}
onChange={next => {
setValue(next)
// Clear the error as soon as the user starts fixing it.
if (error) setError(null)
}}
onComplete={code => {
if (code !== CORRECT_CODE) {
setError('That code is incorrect. Try 123456.')
}
}}
maxLength={6}
aria-invalid={error !== null}
aria-describedby={error ? 'otp-error' : undefined}
containerClassName={cn(
'group flex items-center',
error && 'motion-safe:animate-[otp-shake_450ms_ease-in-out]',
)}
render={({ slots }) => (
<div className="flex">
{slots.map((slot, idx) => (
<div
key={idx}
className={cn(
'relative flex h-14 w-12 items-center justify-center text-[1.375rem] font-medium tabular-nums transition-all duration-200',
'border-y border-r bg-background/40 first:rounded-l-md first:border-l last:rounded-r-md',
'outline outline-0 outline-offset-0',
error
? 'border-destructive/60 text-destructive-foreground outline-destructive/70'
: 'border-border text-foreground outline-foreground/80 group-focus-within:border-foreground/25',
slot.isActive && 'z-10 outline-2',
)}
>
{slot.char}
{slot.hasFakeCaret && <FakeCaret />}
</div>
))}
</div>
)}
/>
<p
id="otp-error"
role="alert"
className={cn(
'text-sm transition-opacity duration-150',
error ? 'text-red-400 opacity-100' : 'opacity-0',
)}
>
{error ?? 'placeholder'}
</p>
</div>
)
}Disabled and read-only#
import * as React from 'react'
import { OTPInput } from 'input-otp'
import { Slot } from '@/components/ui/otp-slot'
export function DisabledDemo() {
// Held in state only so the demo can show a pre-filled field.
const [partial, setPartial] = React.useState('042')
const [complete, setComplete] = React.useState('314159')
return (
<div className="flex flex-col items-center gap-6">
{/* `has-[:disabled]` reads the state off the real input, so the whole
field dims without any extra prop threading. */}
<OTPInput
disabled
maxLength={6}
value={partial}
onChange={setPartial}
containerClassName="group flex items-center has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-40"
render={({ slots }) => (
<div className="flex">
{slots.map((slot, idx) => (
<Slot key={idx} {...slot} />
))}
</div>
)}
/>
<OTPInput
readOnly
maxLength={6}
value={complete}
onChange={setComplete}
containerClassName="group flex items-center has-[:read-only]:cursor-default"
render={({ slots }) => (
<div className="flex">
{slots.map((slot, idx) => (
<Slot key={idx} {...slot} />
))}
</div>
)}
/>
</div>
)
}Alphanumeric#
Remember inputMode="text", or mobile users get a keypad with no letters on it.
import { OTPInput, REGEXP_ONLY_DIGITS_AND_CHARS } from 'input-otp'
import { Slot } from '@/components/ui/otp-slot'
export function AlphanumericDemo() {
return (
<OTPInput
maxLength={6}
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
// A numeric keypad can't type letters — ask for the full keyboard.
inputMode="text"
autoCapitalize="characters"
containerClassName="group flex items-center"
render={({ slots }) => (
<div className="flex">
{slots.map((slot, idx) => (
<Slot key={idx} {...slot} className="uppercase" />
))}
</div>
)}
/>
)
}Autofocus#
// Every input attribute is forwarded, autoFocus included.
<OTPInput autoFocus maxLength={6} />
// Prefer this over autoFocus when the field isn't the only thing on screen —
// autoFocus scrolls the page to it on load, which can be disorienting.
const ref = React.useRef<HTMLInputElement>(null)
React.useEffect(() => {
if (userJustRequestedACode) ref.current?.focus()
}, [userJustRequestedACode])Composition#
Labelled field#
A real <label>, a real hint, and no ARIA gymnastics — see Accessibility.
Enter the 6-character code we sent to your phone.
import { OTPInput } from 'input-otp'
import { Slot } from '@/components/ui/otp-slot'
/**
* The field is a real <input>, so it takes a real <label>. Clicking the label
* focuses it and a screen reader announces the name — no ARIA gymnastics.
*/
export function LabelledDemo() {
return (
<div className="flex flex-col items-center gap-3">
<label
htmlFor="verification-code"
className="text-sm font-medium text-foreground"
>
Verification code
</label>
<OTPInput
id="verification-code"
name="code"
maxLength={6}
aria-describedby="verification-code-hint"
containerClassName="group flex items-center"
render={({ slots }) => (
<div className="flex">
{slots.map((slot, idx) => (
<Slot key={idx} {...slot} />
))}
</div>
)}
/>
<p id="verification-code-hint" className="text-sm text-muted-foreground">
Enter the 6-character code we sent to your phone.
</p>
</div>
)
}Named parts instead of a render prop#
import * as React from 'react'
import { OTPInput, OTPInputContext } from 'input-otp'
import { Slot } from '@/components/ui/otp-slot'
/**
* The same field, composed instead of rendered from a callback. Drop the
* `render` prop and pass children: every descendant can read slot state from
* `OTPInputContext`. This is what shadcn/ui's `<InputOTPSlot index={n} />`
* is built on.
*/
export function ContextApiDemo() {
return (
<OTPInput maxLength={6} containerClassName="group flex items-center">
<SlotAt index={0} />
<SlotAt index={1} />
<SlotAt index={2} />
<Separator />
<SlotAt index={3} />
<SlotAt index={4} />
<SlotAt index={5} />
</OTPInput>
)
}
function SlotAt({ index }: { index: number }) {
const { slots } = React.useContext(OTPInputContext)
return <Slot {...slots[index]} />
}
function Separator() {
return (
<div role="separator" aria-orientation="vertical" className="px-3">
<div className="h-1 w-3 rounded-full bg-border" />
</div>
)
}Right-to-left#
أدخل الرمز المكون من ٦ أرقام
import { OTPInput } from 'input-otp'
import { Slot } from '@/components/ui/otp-slot'
/**
* OTP codes are read left-to-right even in RTL layouts, so the slot row keeps
* its direction while the surrounding copy flips. `dir="ltr"` on the container
* is the whole trick.
*/
export function RtlDemo() {
return (
<div dir="rtl" className="flex flex-col items-center gap-3">
<label htmlFor="rtl-code" className="text-sm font-medium text-foreground">
رمز التحقق
</label>
<OTPInput
id="rtl-code"
dir="ltr"
maxLength={6}
containerClassName="group flex items-center"
render={({ slots }) => (
<div className="flex">
{slots.map((slot, idx) => (
<Slot key={idx} {...slot} />
))}
</div>
)}
/>
<p className="text-sm text-muted-foreground">
أدخل الرمز المكون من ٦ أرقام
</p>
</div>
)
}Text alignment#
Not a typography prop — it moves the invisible text, and with it the native caret and the iOS selection bubble. Why it matters.
import * as React from 'react'
import { OTPInput } from 'input-otp'
import { Slot } from '@/components/ui/otp-slot'
const ALIGNMENTS = ['left', 'center', 'right'] as const
/**
* `textAlign` positions the *invisible* text inside the input. It has no effect
* on your slots — it decides where the native caret lands after a tap and where
* iOS anchors its long-press selection bubble.
*/
export function TextAlignDemo() {
const [textAlign, setTextAlign] =
React.useState<(typeof ALIGNMENTS)[number]>('left')
const [value, setValue] = React.useState('1234')
return (
<div className="flex flex-col items-center gap-5">
<div className="inline-flex items-center gap-1 rounded-md border border-border/70 bg-muted/30 p-0.5">
{ALIGNMENTS.map(option => (
<button
key={option}
type="button"
onClick={() => setTextAlign(option)}
aria-pressed={textAlign === option}
className={
textAlign === option
? 'rounded bg-foreground/[0.09] px-2.5 py-1 font-mono text-xs text-foreground'
: 'rounded px-2.5 py-1 font-mono text-xs text-muted-foreground transition-colors duration-150 hover:text-foreground'
}
>
{option}
</button>
))}
</div>
{/* Remounting on change keeps the demo honest: the prop is read when the
input's style object is built. */}
<OTPInput
key={textAlign}
textAlign={textAlign}
maxLength={6}
value={value}
onChange={setValue}
containerClassName="group flex items-center"
render={({ slots }) => (
<div className="flex">
{slots.map((slot, idx) => (
<Slot key={idx} {...slot} />
))}
</div>
)}
/>
</div>
)
}