import {
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot,
} from "@/components/ui/input-otp" ;
export default function InputOTPDemo () {
return (
< InputOTP maxLength = { 6 } aria-label = "One-time 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 >
);
}
CLI Manual
$ pnpm dlx shadcn@latest add @desyne/input-otp
The CLI installs dependencies and any other components this one uses.
import {
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot,
} from "@/components/ui/input-otp" ;
< 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 >
InputOTP wraps the input-otp package. It renders one transparent <input> stretched over the slots; the slots only display what's typed, reading each character and the caret position from context. Render one InputOTPSlot per character, with index from 0 to maxLength - 1.
Not a React Aria component
Unlike the other fields, InputOTP takes native input props such as
disabled and required, not isDisabled or isRequired. Its onChange
receives the new value as a string. The caret needs the animate-caret-blink
keyframes, which the registry adds to your CSS on install.
Input OTP — short verification codes of a fixed length: email or SMS codes, authenticator (TOTP) codes, PINs, recovery codes.
Text Field — codes of variable length, or when you'd rather show a single box (inputMode="numeric", autoComplete="one-time-code").
Number Field — actual quantities. Codes aren't numbers: leading zeros matter.
< InputOTP > { /* container <div> + one real <input> */ }
< InputOTPGroup > { /* joined or separated set of slots */ }
< InputOTPSlot index = { 0 } />
< InputOTPSlot index = { 1 } />
</ InputOTPGroup >
< InputOTPSeparator /> { /* optional visual divider */ }
< InputOTPGroup >…</ InputOTPGroup >
</ InputOTP >
Part Renders Notes InputOTP<div> + <input>The container (containerClassName) is a flex row with a gap and dims when the input is disabled. The input (className, data-slot="input-otp") receives every native input prop. InputOTPGroup<div>variant="joined" (default) draws slots as one segmented box; separated draws individual boxes with a gap.InputOTPSlot<div>Displays the character at index, a blinking fake caret when it's the active empty slot, and a focus ring while active. InputOTPSeparator<div role="separator">A muted minus icon between groups. Purely visual.
variant="separated" on InputOTPGroup renders each slot as its own rounded box. Joined slots read as one field; separated slots suit larger, touch-first layouts.
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp" ;
export default function InputOTPSeparated () {
return (
< InputOTP maxLength = { 6 } aria-label = "Verification code" >
< InputOTPGroup variant = "separated" >
{Array. from ({ length: 6 }, ( _ , i ) => i). map (( i ) => (
< InputOTPSlot key = {i} index = {i} />
))}
</ InputOTPGroup >
</ InputOTP >
);
}
pattern takes a regex source string; characters and pastes that don't match are rejected. The package exports REGEXP_ONLY_DIGITS, REGEXP_ONLY_CHARS and REGEXP_ONLY_DIGITS_AND_CHARS. The mobile keyboard is numeric by default (inputMode="numeric").
import { REGEXP_ONLY_DIGITS } from "input-otp" ;
import { useState } from "react" ;
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp" ;
export default function InputOTPDigitsOnly () {
const [ value , setValue ] = useState ( "" );
return (
< div className = "flex flex-col items-center gap-2" >
< InputOTP
maxLength = { 4 }
pattern = { REGEXP_ONLY_DIGITS }
value = {value}
onChange = {setValue}
aria-label = "PIN"
>
< InputOTPGroup >
< InputOTPSlot index = { 0 } />
< InputOTPSlot index = { 1 } />
< InputOTPSlot index = { 2 } />
< InputOTPSlot index = { 3 } />
</ InputOTPGroup >
</ InputOTP >
< p className = "text-muted-foreground text-sm" >
{value ? `You entered: ${ value }` : "Digits only." }
</ p >
</ div >
);
}
For recovery or invite codes, allow letters with REGEXP_ONLY_DIGITS_AND_CHARS, switch to inputMode="text", and use pasteTransformer to strip spaces or dashes from pasted codes. uppercase on the slots displays letters in capitals; the value keeps the case the user typed.
Paste “K7QD-92XF” — the dash is stripped.
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp" ;
import {
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot,
} from "@/components/ui/input-otp" ;
export default function InputOTPAlphanumeric () {
return (
< div className = "flex flex-col items-center gap-2" >
< InputOTP
maxLength = { 8 }
pattern = { REGEXP_ONLY_DIGITS_AND_CHARS }
inputMode = "text"
autoCapitalize = "characters"
pasteTransformer = {( pasted ) => pasted. replace ( / [\s-] / g , "" )}
aria-label = "Recovery code"
>
< InputOTPGroup >
{[ 0 , 1 , 2 , 3 ]. map (( i ) => (
< InputOTPSlot key = {i} index = {i} className = "uppercase" />
))}
</ InputOTPGroup >
< InputOTPSeparator />
< InputOTPGroup >
{[ 4 , 5 , 6 , 7 ]. map (( i ) => (
< InputOTPSlot key = {i} index = {i} className = "uppercase" />
))}
</ InputOTPGroup >
</ InputOTP >
< p className = "text-muted-foreground text-sm" >
Paste “K7QD-92XF” — the dash is stripped.
</ p >
</ div >
);
}
Slots are 40px squares by default. Resize them with className on each InputOTPSlot (size-* and a text size), and adjust the group's gap for separated slots.
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp" ;
export default function InputOTPSizes () {
return (
< div className = "flex flex-col items-center gap-5" >
< InputOTP maxLength = { 4 } aria-label = "Small code" defaultValue = "12" >
< InputOTPGroup >
{[ 0 , 1 , 2 , 3 ]. map (( i ) => (
< InputOTPSlot key = {i} index = {i} className = "size-8 text-sm" />
))}
</ InputOTPGroup >
</ InputOTP >
< InputOTP maxLength = { 4 } aria-label = "Default code" defaultValue = "12" >
< InputOTPGroup >
{[ 0 , 1 , 2 , 3 ]. map (( i ) => (
< InputOTPSlot key = {i} index = {i} />
))}
</ InputOTPGroup >
</ InputOTP >
< InputOTP maxLength = { 4 } aria-label = "Large code" defaultValue = "12" >
< InputOTPGroup variant = "separated" className = "gap-3" >
{[ 0 , 1 , 2 , 3 ]. map (( i ) => (
< InputOTPSlot
key = {i}
index = {i}
className = "size-14 rounded-lg! text-2xl"
/>
))}
</ InputOTPGroup >
</ InputOTP >
</ div >
);
}
placeholder on InputOTP is split across the slots: slot n shows character n in a muted color. As in input-otp itself, the placeholder is shown only while the whole input is empty, and it's hidden in the slot that has the blinking caret. The string is also exposed to screen readers as aria-placeholder on the input.
import {
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot,
} from "@/components/ui/input-otp" ;
export default function InputOTPPlaceholder () {
return (
< InputOTP maxLength = { 6 } placeholder = "000000" 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 >
);
}
value and onChange hold the code as a string. Set it to "" to clear.
import { useState } from "react" ;
import { Button } from "@/components/ui/button" ;
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp" ;
export default function InputOTPControlled () {
const [ value , setValue ] = useState ( "" );
return (
< div className = "flex flex-col items-center gap-3" >
< InputOTP
maxLength = { 6 }
value = {value}
onChange = {setValue}
aria-label = "Verification code"
>
< InputOTPGroup >
{[ 0 , 1 , 2 , 3 , 4 , 5 ]. map (( i ) => (
< InputOTPSlot key = {i} index = {i} />
))}
</ InputOTPGroup >
</ InputOTP >
< p className = "text-muted-foreground text-sm tabular-nums" >
{value. length }/6 ·{ " " }
< code className = "text-foreground" >{value || "—" }</ code >
</ p >
< Button
size = "sm"
variant = "outline"
isDisabled = { ! value}
onPress = {() => setValue ( "" )}
>
Clear
</ Button >
</ div >
);
}
onComplete fires with the full code when the last slot is filled, so users don't need a submit button. Disable the input while you check it, and announce the result in an aria-live region.
Verifies automatically on the last digit.
import { CheckCircle2Icon } from "lucide-react" ;
import { useState } from "react" ;
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp" ;
import { Spinner } from "@/components/ui/spinner" ;
export default function InputOTPOnComplete () {
const [ status , setStatus ] = useState < "idle" | "checking" | "verified" >(
"idle" ,
);
return (
< div className = "flex flex-col items-center gap-3" >
< InputOTP
maxLength = { 6 }
aria-label = "Verification code"
disabled = {status !== "idle" }
onComplete = { async () => {
setStatus ( "checking" );
await new Promise (( r ) => setTimeout (r, 900 ));
setStatus ( "verified" );
}}
>
< InputOTPGroup variant = "separated" >
{[ 0 , 1 , 2 , 3 , 4 , 5 ]. map (( i ) => (
< InputOTPSlot key = {i} index = {i} />
))}
</ InputOTPGroup >
</ InputOTP >
< p
aria-live = "polite"
className = "flex h-5 items-center gap-2 text-muted-foreground text-sm"
>
{status === "idle" && "Verifies automatically on the last digit." }
{status === "checking" && (
<>
< Spinner size = "xs" label = "Verifying" /> Verifying…
</>
)}
{status === "verified" && (
<>
< CheckCircle2Icon className = "size-4 text-success" /> Verified
</>
)}
</ p >
</ div >
);
}
Slots turn red when they carry aria-invalid. Set it on each InputOTPSlot for the visual state, and on InputOTP (with aria-describedby pointing at the message) so screen readers hear it.
That code is incorrect. Try 123456.
import { useState } from "react" ;
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp" ;
export default function InputOTPInvalid () {
const [ value , setValue ] = useState ( "481920" );
const invalid = value. length === 6 && value !== "123456" ;
return (
< div className = "flex flex-col items-center gap-2" >
< InputOTP
maxLength = { 6 }
value = {value}
onChange = {setValue}
aria-label = "Verification code"
aria-invalid = {invalid}
aria-describedby = "otp-invalid-error"
>
< InputOTPGroup >
{[ 0 , 1 , 2 , 3 , 4 , 5 ]. map (( i ) => (
< InputOTPSlot key = {i} index = {i} aria-invalid = {invalid} />
))}
</ InputOTPGroup >
</ InputOTP >
< p
id = "otp-invalid-error"
aria-live = "polite"
className = {
invalid ? "text-destructive text-xs" : "text-muted-foreground text-xs"
}
>
{invalid
? "That code is incorrect. Try 123456."
: "Enter the 6-digit code." }
</ p >
</ div >
);
}
disabled disables the input; the container dims through has-disabled:opacity-50.
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp" ;
export default function InputOTPDisabled () {
return (
< InputOTP
maxLength = { 6 }
defaultValue = "2048"
disabled
aria-label = "Verification code"
>
< InputOTPGroup >
{[ 0 , 1 , 2 , 3 , 4 , 5 ]. map (( i ) => (
< InputOTPSlot key = {i} index = {i} />
))}
</ InputOTPGroup >
</ InputOTP >
);
}
The real input submits under its name. Use required and minLength equal to maxLength for native validation, and link a visible <label> with id / htmlFor.
import { useState } from "react" ;
import { Button } from "@/components/ui/button" ;
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp" ;
export default function InputOTPForm () {
const [ data , setData ] = useState < Record < string , FormDataEntryValue > | null >(
null ,
);
return (
< form
className = "flex flex-col items-center gap-4"
onSubmit = {( e ) => {
e. preventDefault ();
setData (Object. fromEntries ( new FormData (e.currentTarget)));
}}
>
< div className = "flex flex-col items-center gap-2" >
< label htmlFor = "otp-form-code" className = "font-medium text-sm" >
Authenticator code
</ label >
< InputOTP
id = "otp-form-code"
name = "code"
maxLength = { 6 }
minLength = { 6 }
required
>
< InputOTPGroup >
{[ 0 , 1 , 2 , 3 , 4 , 5 ]. map (( i ) => (
< InputOTPSlot key = {i} index = {i} />
))}
</ InputOTPGroup >
</ InputOTP >
</ div >
< Button type = "submit" >Verify</ Button >
{data && (
< code className = "rounded-md bg-muted px-2 py-1 text-xs" >
{ JSON . stringify (data)}
</ code >
)}
</ form >
);
}
A verification card: the heading labels the input through aria-labelledby, submit stays disabled until all six digits are in, and a resend link has a cooldown.
import { MailCheckIcon } from "lucide-react" ;
import { useEffect, useState } from "react" ;
import { Button } from "@/components/ui/button" ;
import {
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot,
} from "@/components/ui/input-otp" ;
export default function InputOTPRecipeVerifyEmail () {
const [ code , setCode ] = useState ( "" );
const [ cooldown , setCooldown ] = useState ( 30 );
const [ pending , setPending ] = useState ( false );
useEffect (() => {
if (cooldown === 0 ) return ;
const t = setTimeout (() => setCooldown (( c ) => c - 1 ), 1000 );
return () => clearTimeout (t);
}, [cooldown]);
return (
< form
className = "flex w-full max-w-sm flex-col items-center gap-5 rounded-xl border bg-card p-6 text-center"
onSubmit = { async ( e ) => {
e. preventDefault ();
setPending ( true );
await new Promise (( r ) => setTimeout (r, 900 ));
setPending ( false );
}}
>
< span className = "flex size-10 items-center justify-center rounded-full bg-muted" >
< MailCheckIcon className = "size-5" />
</ span >
< div className = "flex flex-col gap-1" >
< h3 id = "verify-email-title" className = "font-semibold" >
Check your email
</ h3 >
< p className = "text-muted-foreground text-sm" >
We sent a 6-digit code to{ " " }
< span className = "text-foreground" >ada@acme.dev</ span >.
</ p >
</ div >
< InputOTP
maxLength = { 6 }
name = "code"
value = {code}
onChange = {setCode}
aria-labelledby = "verify-email-title"
autoFocus
>
< InputOTPGroup >
< InputOTPSlot index = { 0 } />
< InputOTPSlot index = { 1 } />
< InputOTPSlot index = { 2 } />
</ InputOTPGroup >
< InputOTPSeparator />
< InputOTPGroup >
< InputOTPSlot index = { 3 } />
< InputOTPSlot index = { 4 } />
< InputOTPSlot index = { 5 } />
</ InputOTPGroup >
</ InputOTP >
< Button
type = "submit"
className = "w-full"
isDisabled = {code. length < 6 }
isPending = {pending}
>
Verify email
</ Button >
< p className = "text-muted-foreground text-sm" >
Didn't get it?{ " " }
< Button
variant = "link"
size = "sm"
className = "h-auto p-0"
isDisabled = {cooldown > 0 }
onPress = {() => setCooldown ( 30 )}
>
{cooldown > 0 ? `Resend in ${ cooldown }s` : "Resend code" }
</ Button >
</ p >
</ form >
);
}
Authenticator codes with a switch to 10-character recovery codes. Changing mode remounts the input (key) with a new maxLength, pattern and inputMode; a wrong code marks the slots invalid.
Two-factor authentication Enter the code from your authenticator app.
Use a recovery code instead
import { REGEXP_ONLY_DIGITS, REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp" ;
import { ShieldCheckIcon } from "lucide-react" ;
import { Fragment, useState } from "react" ;
import { Button } from "@/components/ui/button" ;
import {
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot,
} from "@/components/ui/input-otp" ;
export default function InputOTPRecipeTwoFactor () {
const [ mode , setMode ] = useState < "app" | "recovery" >( "app" );
const [ error , setError ] = useState ( false );
const length = mode === "app" ? 6 : 10 ;
const slots = Array. from ({ length }, ( _ , i ) => i);
return (
< div className = "flex w-full max-w-sm flex-col items-center gap-5 rounded-xl border bg-card p-6 text-center" >
< ShieldCheckIcon className = "size-6 text-muted-foreground" />
< div className = "flex flex-col gap-1" >
< h3 id = "two-factor-title" className = "font-semibold" >
Two-factor authentication
</ h3 >
< p className = "text-muted-foreground text-sm" >
{mode === "app"
? "Enter the code from your authenticator app."
: "Enter one of your 10-character recovery codes." }
</ p >
</ div >
< InputOTP
key = {mode}
maxLength = {length}
pattern = {
mode === "app" ? REGEXP_ONLY_DIGITS : REGEXP_ONLY_DIGITS_AND_CHARS
}
inputMode = {mode === "app" ? "numeric" : "text" }
aria-labelledby = "two-factor-title"
aria-invalid = {error}
aria-describedby = {error ? "two-factor-error" : undefined }
onChange = {() => setError ( false )}
onComplete = {( code : string ) => setError (code !== "000000" )}
>
{[slots. slice ( 0 , length / 2 ), slots. slice (length / 2 )]. map (
( group , g ) => (
< Fragment key = {group[ 0 ]}>
{g === 1 && < InputOTPSeparator />}
< InputOTPGroup >
{group. map (( index ) => (
< InputOTPSlot
key = {index}
index = {index}
aria-invalid = {error}
className = {
mode === "recovery"
? "size-8 text-sm uppercase"
: undefined
}
/>
))}
</ InputOTPGroup >
</ Fragment >
),
)}
</ InputOTP >
< p
id = "two-factor-error"
aria-live = "polite"
className = "min-h-4 text-destructive text-xs"
>
{error && "Invalid code. Try 000000." }
</ p >
< Button
variant = "link"
size = "sm"
onPress = {() => {
setError ( false );
setMode (( m ) => (m === "app" ? "recovery" : "app" ));
}}
>
{mode === "app"
? "Use a recovery code instead"
: "Use authenticator app" }
</ Button >
</ div >
);
}
A single native <input> holds the value, so screen readers announce one text field, and paste, SMS one-time-code autofill (autoComplete="one-time-code" is the default) and password managers work.
Always provide a label: aria-label, aria-labelledby, or a <label htmlFor> pointing at the input's id. The slots are visual only.
The separator has role="separator" and a decorative icon.
Announce verification results and errors with aria-live, and set aria-invalid plus aria-describedby on InputOTP for invalid codes; the slot styling alone isn't announced.
Prefer onComplete for auto-verify, but keep a way to retry and don't clear the code without telling the user.
Key Action Typing Fills the active slot and moves to the next one Backspace Deletes the previous character ← / → Moves the caret between slots ⌘ / Ctrl + V Pastes a whole code, after pasteTransformer and pattern checks
Attribute Element Present when data-active="true"InputOTPSlotThe caret or selection is on this slot while the input is focused data-placeholderInputOTPSlotThe slot is showing its placeholder character aria-invalidInputOTPSlotYou pass it; draws a destructive border data-variantInputOTPGroupAlways: joined or separated data-input-otp-containerContainer Always (set by the package)
Slots read their group's variant through group/otp, so group-data-[variant=separated]/otp: targets separated slots from your own classes.
data-slotElement input-otpThe real <input> input-otp-groupGroup input-otp-slotSlot input-otp-separatorSeparator
containerClassName styles the row around the slots; className styles the transparent input itself. The fake caret uses the animate-caret-blink utility.
Also accepts every native <input> attribute. See the input-otp docs for the full API.
Also accepts every <div> prop.
Also accepts every <div> prop. While the input is empty, the slot renders its character of the input's placeholder (muted, aria-hidden).
Accepts every <div> prop. Renders role="separator" with a minus icon.