The questions that come up most often, in rough order of frequency. Nearly all of them are one of three things: styles on the wrong element, a pattern that rejects partial values, or a missing 'use client'.
Styling#
My styles do nothing#
You almost certainly put them on className, which goes to the invisible input. Visible styling belongs on containerClassName.
// Nothing appears to happen: these styles land on the invisible input.
<OTPInput className="flex items-center gap-2" />
// This is what you meant.
<OTPInput containerClassName="flex items-center gap-2" />There's an unwanted ring or border on focus#
Your CSS reset or design system is styling input:focus, and the invisible input is an input. Cancel it there — on className, not the container:
<OTPInput
// on the input itself — this is where the stray ring comes from
className="focus-visible:ring-0 focus-visible:outline-none"
// not here
containerClassName="group flex items-center"
/>The active slot's outline is clipped#
With a shared border, the next slot paints over the previous one's outline. Raise the active slot:
// The active slot's ring is clipped by its neighbour's border.
className={cn('relative', isActive && 'z-10 outline-2')}containerClassName has no Tailwind autocomplete#
It isn't named className, so the extension ignores it. Add this to .vscode/settings.json:
{ "tailwindCSS.classAttributes": ["class", "className", ".*ClassName"] }Input and validation#
I can't type letters#
Two independent causes, and it's usually both: a digits-only pattern, and inputMode still at its numeric default so the mobile keyboard has no letters on it.
<OTPInput
maxLength={6}
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
inputMode="text" // ← without this, mobile gets a keypad
autoCapitalize="characters"
autoCorrect="off"
spellCheck={false}
/>Nothing can be typed at all#
Your pattern pins the length. It is tested against every intermediate value, so a one-character value has to pass:
pattern="^\d{6}$" // ✗ the first keystroke fails, so nothing can be typed
pattern={REGEXP_ONLY_DIGITS} // ✓ '^\d+$' — accepts every partial valuePasting a code does nothing#
The clipboard text has something your pattern rejects — a hyphen, a space, a trailing newline — and a failed pattern discards the whole paste rather than filtering it.
// A strict pattern rejects the whole paste, hyphens and all.
<OTPInput
pattern={REGEXP_ONLY_DIGITS}
pasteTransformer={pasted => pasted.replace(/[^0-9]/g, '')}
/>The value never changes#
onChange receives a string, not an event. Reading e.target.value off it gives you undefined:
// onChange gives you a string, not an event.
<OTPInput value={value} onChange={setValue} /> // ✓
<OTPInput value={value} onChange={e => setValue(e.target.value)} /> // ✗onComplete fires more than once per code#
It fires on the transition into a full value, so editing a complete code and refilling it fires again — that is intended. If you are seeing duplicate network requests, disable the field while the request is in flight; see Forms.
Setup#
"useState only works in a Client Component"#
The component needs the browser. In the Next.js App Router, the file that renders it has to be a client component:
'use client' // ← at the top of the file that renders OTPInput
import { OTPInput } from 'input-otp'Cannot read properties of undefined (reading 'char')#
A composed slot is reading OTPInputContext from outside an OTPInput. The context defaults to an empty object rather than throwing, so the failure surfaces one level down:
// Reading the context outside an OTPInput gives you {} — so slots is undefined.
const { slots } = React.useContext(OTPInputContext)
return <div>{slots[index].char}</div> // 💥
// The slot component has to be a descendant of the field:
<OTPInput maxLength={6}>
<Slot index={0} />
</OTPInput>"Input elements must be either controlled or uncontrolled"#
Passing defaultValue produces this React warning. The component always renders the input with a value — it reads defaultValue to seed its internal state, and then forwards it to the input along with everything else, so React sees both. It is noise rather than breakage: the value seeds correctly either way. To keep the console clean, seed the state yourself instead:
// Warns: value and defaultValue both reach the input.
<OTPInput maxLength={6} defaultValue="123" />
// No warning, same result.
const [value, setValue] = React.useState('123')
<OTPInput maxLength={6} value={value} onChange={setValue} />Hydration mismatch on first load#
Usually because something in your slot markup depends on a value the server can't know. Note that isFocused and isHovering are both false on the server, and the selection mirror is null — so no slot is active in the initial HTML, by design. Don't branch your markup structure on those; branch classes.
Password managers#
The badge still covers my last slot#
The reserved gutter is a fixed 40px. A wider badge, or one anchored further in than 18px, will overlap anyway. Widen it yourself:
/* Badge wider than the 40px the library reserves. */
[data-input-otp] {
width: calc(100% + 56px) !important;
clip-path: inset(0 56px 0 0) !important;
}Or check whether the accommodation ran at all — input.style.width should read calc(100% + 40px). The simulator shows both values live.
I don't want a badge on this field at all#
Turn off the library's accommodation and opt out with each vendor's own attribute — the exact set is here. Do both, or you get no badge and 40px reserved for one.
Behaviour that looks like a bug and isn't#
- Typing replaces the character under the caret rather than inserting before it. Deliberate: a collapsed caret is widened to a one-character range so a slot can be highlighted, which makes typing overwrite. The exception is the append position at the end of a partial code.
- Several slots highlight at once. That's a real multi-character selection — ⇧-arrow or a drag. All the covered slots report
isActive. - No slot is highlighted when the field is blurred. The mirror is cleared on blur, so nothing is active. Style
focus-withinif you want a resting state. - The input is 40px wider than the container in devtools. Password manager accommodation. The extra width is clipped away and hit-testing is unaffected; the details, including a note on when it fires more eagerly than intended.
- A
<style id="input-otp-style">appeared inhead. One per page, inserted once. Every rule is scoped to[data-input-otp].
Still stuck#
Open an issue on GitHub. A minimal reproduction plus the browser and OS gets you an answer much faster — and if it involves iOS or a password manager, say so up front, because neither can be reproduced in a headless browser.