input-otp is a single component with no dependencies beyond React. There is no provider to mount, no CSS file to import and no config step.
Install the package
$ pnpm add input-otpReact 16.8 or newer is the only peer dependency, up to and including React 19.
Render a field
maxLength is the number of slots. render receives them and returns your markup — that's the entire contract.
'use client'
import { OTPInput } from 'input-otp'
export function VerificationCode() {
return (
<OTPInput
maxLength={6}
render={({ slots }) => (
<div style={{ display: 'flex', gap: 4 }}>
{slots.map((slot, idx) => (
<div key={idx} style={{ width: 40, height: 52, border: '1px solid #333' }}>
{slot.char}
</div>
))}
</div>
)}
/>
)
}Make it look like something
The starter above is deliberately ugly. Below is the slot component used throughout these docs — copy it into your project as components/ui/otp-slot.tsx and you have a field you can ship.
import * as React from 'react'
import type { SlotProps } from 'input-otp'
import { cn } from '@/lib/utils'
/**
* The slot: one visible cell of the field.
*
* `input-otp` never renders this — it hands you `char`, `placeholderChar`,
* `isActive` and `hasFakeCaret` and gets out of the way. Everything below is
* plain markup you own.
*/
export function Slot({
char,
placeholderChar,
isActive,
hasFakeCaret,
className,
}: SlotProps & { className?: string }) {
return (
<div
className={cn(
'relative flex h-14 w-12 items-center justify-center',
'text-[1.375rem] font-medium tabular-nums text-foreground',
'border-y border-r border-foreground/[0.18] bg-foreground/[0.02]',
'first:rounded-l-md first:border-l last:rounded-r-md',
'transition-all duration-200',
// The group-* hooks come from `containerClassName="group …"`.
'group-hover:border-foreground/30 group-focus-within:border-foreground/30',
'outline outline-0 outline-offset-0 outline-foreground/20',
isActive && 'z-10 outline-2 outline-foreground/80',
className,
)}
>
{/* Placeholder characters are dimmed via a data attribute the library
sets on the real input while the value is empty — no JS needed. */}
<div className="group-has-[input[data-input-otp-placeholder-shown]]:text-muted-foreground/40">
{char ?? placeholderChar}
</div>
{hasFakeCaret && <FakeCaret />}
</div>
)
}
/** The blinking bar. The real caret is transparent, so we draw our own. */
export function FakeCaret() {
return (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-7 w-px bg-foreground motion-safe:animate-caret-blink" />
</div>
)
}
/** Stripe-style dash between two groups of slots. */
export function FakeDash() {
return (
<div aria-hidden className="flex w-10 items-center justify-center">
<div className="h-1 w-3 rounded-full bg-border" />
</div>
)
}
export function SlotGroup({
children,
className,
}: {
children: React.ReactNode
className?: string
}) {
return <div className={cn('flex', className)}>{children}</div>
}It expects Tailwind, the cn helper from shadcn/ui, and one keyframe for the blinking caret:
// tailwind.config.ts
export default {
theme: {
extend: {
keyframes: {
'caret-blink': {
'0%,70%,100%': { opacity: '1' },
'20%,50%': { opacity: '0' },
},
},
animation: {
'caret-blink': 'caret-blink 1.2s ease-out infinite',
},
},
},
}Wire it up
Which leaves you here:
Already using shadcn/ui?#
shadcn/ui's input-otp component is a thin wrapper around this library — same engine, pre-composed parts. If you want <InputOTPSlot index={0} /> instead of a render prop, install it from the registry:
npx shadcn@latest add input-otpIt uses the Context API form of this component under the hood, so everything in these docs still applies.
Editor setup#
containerClassName won't get Tailwind IntelliSense out of the box because it isn't named className. One setting fixes it for every *ClassName prop:
// .vscode/settings.json
{
"tailwindCSS.classAttributes": ["class", "className", ".*ClassName"]
}Verifying the install#
A field that's wired up correctly should pass all of these on the first try — if any of them fail, something in the setup is off:
- Clicking anywhere in the row of slots focuses the field and puts the caret in a sensible place.
- ⌘A selects the whole code, and typing replaces it.
- Pasting a full code fills every slot at once.
- Tabbing away removes the active-slot highlight; tabbing back restores it at the end of the value.
Next#
Anatomy shows what you just rendered, from the inside.