One invisible input, any UI you can imagine.
The accessible, unstyled, fully featured one-time-password component for React.
Documentation Β· Examples Β· API Β· Edge cases
Clerk β the easiest way to add authentication to your application
|
|
|
Evomi β residential proxies from $0.49 Β· Rapidproxy β residential proxies from $0.55
HTML has no one-time-password control. There is no <input type="otp">, so most
products build one out of six separate inputs wired together with keydown
handlers that shuffle focus between them β and quietly lose SMS autofill, screen
reader support, partial paste, undo, and half the keyboard along the way.
input-otp renders exactly one real text input, paints it invisible, and
hands you the state to draw whatever you want on top. Everything the browser
gives a text field keeps working, because there is still a text field.
- SMS autofill β
autocomplete="one-time-code"only means something on a single field - Screen readers β one control, one name, one value, one caret, one tab stop
- Every keybinding you didn't implement β select-all, word-delete, shift-arrow ranges, undo, the iOS long-press menu
- Real paste β including a partial paste into the middle of a half-filled code
- Form semantics β one
name, one entry inFormData, a real<label>that focuses it - Unstyled β no theme, no class names to override, no CSS to import
- Small β zero dependencies, React 16.8 β 19 (see the size badge above)
npm install input-otpmaxLength is the number of slots. render receives them and returns your
markup β that's the whole contract.
'use client'
import { OTPInput } from 'input-otp'
export function VerificationCode() {
return (
<OTPInput
maxLength={6}
containerClassName="group flex items-center"
render={({ slots }) => (
<div className="flex">
{slots.map((slot, idx) => (
<Slot key={idx} {...slot} />
))}
</div>
)}
/>
)
}Each slot tells you what to draw:
import type { SlotProps } from 'input-otp'
function Slot({ char, placeholderChar, isActive, hasFakeCaret }: SlotProps) {
return (
<div
className={cn(
'relative flex h-14 w-12 items-center justify-center',
'border-y border-r border-border first:rounded-l-md first:border-l last:rounded-r-md',
'text-[1.375rem] font-medium tabular-nums transition-all duration-200',
'outline outline-0 outline-foreground/80',
isActive && 'z-10 outline-2', // this slot is being edited
)}
>
{char ?? placeholderChar}
{hasFakeCaret && <FakeCaret />} {/* the real caret is transparent */}
</div>
)
}The full, copy-pasteable slot component (with the caret keyframe and the Stripe-style dash) is in Installation.
shadcn/ui's input-otp component wraps this library with pre-composed parts.
Same engine, <InputOTPSlot index={0} /> instead of a render prop:
npx shadcn@latest add input-otpThe API is five props. The value is the list of things that go wrong when one invisible input has to behave like six boxes β and the fix for each:
| A collapsed caret has no slot | The selection is rewritten into a one-character range on every selectionchange β except at the append position, where a bare caret is meaningful |
ArrowLeft appears to skip a slot |
Direction is inferred from the previous selection, with a guard for leaving insert mode |
Deleting doesn't fire selectionchange |
The event is dispatched by hand when the value shrinks |
| Password manager badges cover the last slot | A badge is detected by known extension markers, then by probing the field's top-right corner; the input widens 40px behind a clip-path β no visible layout shift |
| iOS won't paste into an invisible input | The field keeps opacity: 1 and hides itself with transparent colours; paste is handled manually |
| Autofill paints its own background | :autofill is neutralised, and the state is shaken off with a synthetic input event |
| No JavaScript means no visible field | A <noscript> stylesheet turns the input back into a plain visible one |
Each of these β and a dozen more β is written up with the reasoning and the exact code in Edge cases.
| Introduction | Why one input, and what you write |
| Installation | Install, first render, a slot component to copy |
| Anatomy | X-ray the field and watch the selection algorithm run live |
| Styling | Slots, carets, placeholders, groups, data attributes |
| Validation | pattern, pasteTransformer, inputMode |
| Forms | Controlled values, auto-submit, react-hook-form, server actions |
| Accessibility | Labelling, keyboard, what a screen reader hears |
| Password managers | How badge detection works β with a live simulator |
| Mobile & platforms | SMS autofill, iOS quirks, autofill styling, no-JS |
| API reference | Every prop, render prop, data attribute and export |
| Examples | A gallery of finished fields to copy |
| Troubleshooting | The questions that come up most |
type OTPInputProps = {
maxLength: number // number of slots β required
render?: (props: RenderProps) => React.ReactNode
children?: React.ReactNode // β¦or compose and read OTPInputContext
value?: string
onChange?: (newValue: string) => unknown // a string, not an event
onComplete?: (value: string) => unknown // fires once, on the transition to full
pattern?: string | RegExp // gates every change; no default
placeholder?: string // per-slot placeholder characters
pasteTransformer?: (pasted: string) => string
containerClassName?: string // the visible wrapper
// className goes to the invisible input
textAlign?: 'left' | 'center' | 'right' // default 'left'
inputMode?: 'numeric' | 'text' | ... // default 'numeric'
pushPasswordManagerStrategy?: 'increase-width' | 'none'
noScriptCSSFallback?: string | null
nonce?: string // for CSP style-src β applied to the injected <style> tag
}
interface SlotProps {
char: string | null
placeholderChar: string | null
isActive: boolean
hasFakeCaret: boolean
}Every other <input> attribute is forwarded β name, required, disabled,
autoFocus, aria-*, data-* β and ref points at the real input.
spellCheck defaults to false (browsers would underline a full code as a
typo); pass spellCheck yourself to override.
Full reference: input-otp.rodz.dev/docs/api.
pnpm install
pnpm build:lib # tsup β packages/input-otp/dist
pnpm dev:playground # the Playwright target, port 3039
pnpm test # Playwright, all browsersTests live in apps/playground/src/tests. Note that the iOS code path, SMS
autofill and password manager badges cannot be covered headlessly β see
Mobile & platforms.
|
|
|
- Clerk β the easiest way to add authentication to your application
- Evomi β residential proxies from $0.49
- Rapidproxy β residential proxies from $0.55
MIT Β© Guilherme Rodz