Troubleshooting

Answers to the questions that come up most often in issues and discussions.

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 value

Pasting 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-within if 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 in head. 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.