Edge cases

The complete catalogue of browser and platform quirks this library absorbs, and the exact fix for each one.

This is the real content of the library. The API is four or five props; the value is the list below — every place where "one invisible input pretending to be six boxes" collides with how browsers actually behave, and what it costs to absorb each collision.

Each entry is written the same way on purpose: what you would see, why it happens, and what the library does. Most of them are one or two lines of code that took a bug report to find.

The selection algorithm#

Four related problems, all downstream of one fact: a text caret can sit between two characters, and a row of slots has no way to draw that.

A collapsed caret has no slot

Symptom
With the caret between slot 2 and slot 3, either both highlight or neither does — and typing sometimes inserts, sometimes overwrites.
Cause
selectionStart === selectionEnd is a position between characters. "Which slot is active" is not a question that position can answer.
Fix
On every selectionchange, widen a collapsed caret into a one-character range with setSelectionRange(start, end, direction). Exactly one slot is active, and typing overwrites it — which is what people expect from a code field.
if (isSingleCaret && !isInsertMode) {
  if (c === 0) {
    [start, end, direction] = [0, 1, 'forward']
  } else if (c === maxLength) {
    [start, end, direction] = [c - 1, c, 'backward']
  } else if (maxLength > 1 && value.length > 1) {
    // …direction-aware, see below
  }
  input.setSelectionRange(start, end, direction)
}

…except when you're appending

Symptom
After typing three characters, the fourth keystroke replaces the third instead of adding to it.
Cause
The blanket widening above also catches the legitimate insert caret at the end of a partial value, turning an append into an overwrite.
Fix
Detect insert mode and exempt it. A collapsed caret at the end of a not-yet-full value is meaningful.
const isInsertMode = start === value.length && value.length < maxLength
 
// A collapsed caret is only meaningful here: at the end of a code that
// isn't full yet. Widening it would select the last character, and the
// next keystroke would replace it instead of appending.

Pressing ArrowLeft appears to skip a slot

Symptom
Moving left with jumps two slots at a time — and it only happens when arriving from the end of the value.
Cause
A caret at index c borders slot c-1 and slot c. Which one the user meant depends on the direction they arrived from, and the selection API doesn't tell you.
Fix
Keep the previous [start, end, direction] in a ref, infer direction by comparing against it, and shift the range one slot left on a backward move. The wasPreviouslyInserting guard suppresses that shift when leaving insert mode, where index c already names the right slot.
direction = c < prevEnd ? 'backward' : 'forward'
 
const wasPreviouslyInserting = prevStart === prevEnd && prevStart < maxLength
if (direction === 'backward' && !wasPreviouslyInserting) {
  offset = -1
}
 
[start, end] = [offset + c, offset + c + 1]

Deleting doesn't fire selectionchange

Symptom
Press or cut a selection and the highlighted slot goes stale — it stays where the old selection was until you move the caret.
Cause
No browser fires selectionchange for a deletion or a cut, even though the selection demonstrably changed. The mirror never learns about it.
Fix
Compare lengths in the change handler and dispatch the event by hand when the value shrank.
const maybeHasDeleted =
  typeof previousValue === 'string' && newValue.length < previousValue.length
 
if (maybeHasDeleted) {
  // Cutting and deleting don't fire selectionchange, so fire it ourselves.
  document.dispatchEvent(new Event('selectionchange'))
}

Making an input invisible#

The selection highlight is still painted

Symptom
Select the code and a blue band appears across the slots — the browser drawing its own selection over your UI.
Cause
color: transparent hides the glyphs but not the selection highlight, which is painted by ::selection and ignores the element's own colour.
Fix
Neutralise both halves of it. Setting only the background leaves the selected text drawn in the highlight's foreground colour — visible again.
[data-input-otp]::selection {
  background: transparent !important;
  color: transparent !important;
}

Autofill paints its own background

ChromiumWebKit
Symptom
After a password manager or SMS autofill, a pale yellow rectangle sits on top of the slots.
Cause
The :autofill pseudo-class carries UA styles that outrank almost anything you write — including a plain background: transparent.
Fix
Override every property it touches with !important, including -webkit-text-fill-color, which is what actually controls the text colour in that state.
[data-input-otp]:autofill,
[data-input-otp]:-webkit-autofill {
  background: transparent !important;
  color: transparent !important;
  border-color: transparent !important;
  opacity: 0 !important;
  box-shadow: none !important;
  -webkit-box-shadow: none !important;
  -webkit-text-fill-color: transparent !important;
}

The :autofill state outlives the autofill

Chromium
Symptom
The yellow tint stays until the user types something.
Cause
Some browsers clear :autofill only on the next real input event, not when the value changes programmatically.
Fix
Dispatch a synthetic input event — and do it three times, at 0ms, 10ms and 50ms, because different engines settle at different moments and none of them signals when they're done.
export function syncTimeouts(cb: () => unknown) {
  return [
    setTimeout(cb, 0),   // fast machines
    setTimeout(cb, 10),
    setTimeout(cb, 50),
  ]
}

insertRule throws and takes the component with it

Symptom
The whole field fails to mount in an environment with a restrictive Content-Security-Policy, or when a browser doesn't recognise one of the rules.
Cause
CSSStyleSheet.insertRule throws on an unparseable rule or a blocked stylesheet, and the throw happens inside an effect.
Fix
Insert each rule individually, inside a try/catch. A rule that can't be applied logs and is skipped; the rest still land.
function safeInsertRule(sheet: CSSStyleSheet, rule: string) {
  try {
    sheet.insertRule(rule)
  } catch {
    console.error('input-otp could not insert CSS rule:', rule)
  }
}

iOS#

iOS refuses to paste into an invisible input

iOS
Symptom
Long-press the field on an iPhone and no Paste item appears in the menu — or no menu at all.
Cause
iOS suppresses the editing menu for inputs it considers non-visible, and opacity: 0 qualifies.
Fix
Never use opacity: 0. The input keeps opacity: 1 and hides itself through transparent color, caret-color, background and ::selection instead. This one constraint is why the injected stylesheet exists.
opacity: '1', // Mandatory for iOS hold-paste

The native selection shows through the invisible input

iOS
Symptom
A thin, caret-tall line appears in the field whenever a range is selected — the artifact tracked in #32. Fixed in 1.5.0-beta.1.
Cause
iOS paints the selection highlight and caret in a native layer that ignores ::selection, CSS opacity and ancestor clipping. The one thing it respects is the rendered text geometry.
Fix
An iOS-only block parks the text offscreen (text-indent) so nothing paints at rest, and scales the input down 10x — with a compensating 10x layout box, so the tap area still matches the container — which floors the highlight at iOS's ~2px minimum. Computed font-size stays at 16px, below which focusing would zoom the page. During a pointer gesture the text is revealed at the fingertip via an inline text-indent so the copy/paste menu can anchor, and hidden again on typing, blur or scroll. The left: -1px / right: 1px pair survives from the old metrics fix: the nudge that repositioned the glyphs also moved the field, so the second declaration restores it.
@supports (-webkit-touch-callout: none) {
  [data-input-otp] {
    font-size: 16px !important;       /* the focus-zoom threshold */
    width: 1000% !important;          /* 10x layout box…           */
    height: 1000% !important;
    transform: scale(0.1) !important; /* …painted at 1/10th        */
    transform-origin: 0 0 !important;
    letter-spacing: -.6em !important;
    text-indent: -9999px !important;  /* park the text offscreen   */
    left: -1px !important;
    right: 1px !important;
  }
}

Native paste inserts the wrong value

iOS
Symptom
Pasting a code on iOS produces a mangled or truncated value.
Cause
The browser's own insertion doesn't agree with the field's collapsed metrics and rewritten selection.
Fix
Handle onPaste directly: read clipboardData, preventDefault(), splice at the caret (replacing the selection if any), truncate to maxLength, test the pattern, then restore the selection explicitly so a full paste leaves the last slot selected instead of a caret past the end. Passing pasteTransformer enables this path on every platform.
input.value = newValue
onChange(newValue)
 
const start = Math.min(newValue.length, maxLength - 1)
const end = newValue.length
input.setSelectionRange(start, end)
setMirrorSelectionStart(start)
setMirrorSelectionEnd(end)

CSS.supports doesn't exist during SSR

Symptom
TypeError: Cannot read properties of undefined when the component renders on a server.
Cause
iOS detection uses window.CSS.supports, and there is no window in Node — nor a CSS object in some non-browser DOM shims.
Fix
Guard the whole chain, not just window.
isIOS:
  typeof window !== 'undefined' &&
  window?.CSS?.supports?.('-webkit-touch-callout', 'none')

Geometry and hit testing#

Clicks land on your slots instead of the field

Symptom
Clicking a slot does nothing, or focuses the field but drops the caret at the wrong index.
Cause
Your decorative markup is painted in the same box as the input. Whichever element wins the hit test receives the click.
Fix
Make the container and the input's wrapper pointer-events: none, and give the input pointer-events: all. Clicks fall straight through the decoration to the one element that should have them, so the browser places the caret at the character nearest the click — the slot the user aimed at.
// container
{ position: 'relative', pointerEvents: 'none', userSelect: 'none' }
// the wrapper around the input
{ position: 'absolute', inset: 0, pointerEvents: 'none' }
// the input itself
{ pointerEvents: 'all' }

The native caret and selection are the wrong size

Symptom
Native UI — the selection band, drag handles, the iOS magnifier — hugs a thin line in the middle of a tall field instead of matching the slots.
Cause
Those affordances are sized from the text, and the text has no idea how tall your slots are.
Fix
A ResizeObserver publishes the container's pixel height as --root-height, and the input's font-size is set from it. It is measured on the container rather than the input because on iOS the input's layout box is enlarged 10x by the scale-down fix. Native UI then matches the boxes the user can see.
const updateRootHeight = () => {
  container.style.setProperty('--root-height', `${container.clientHeight}px`)
}
updateRootHeight()
new ResizeObserver(updateRootHeight).observe(container)
 
// …consumed by the input's own style:
fontSize: 'var(--root-height)'

Firefox loses the selection direction

Firefox
Symptom
Extending a selection leftwards collapses it, or moves the wrong end of the range.
Cause
setSelectionRange(start, end) without a direction defaults to forward, discarding the fact that the user was selecting backwards.
Fix
Always pass the third argument. The algorithm already computes direction — it just has to be handed over.

Password managers#

The badge covers your last slot

Extensions
Symptom
1Password, LastPass, Dashlane or Bitwarden draws its icon over the sixth character.
Cause
Extensions anchor their badge to the input's top-right corner. For an OTP field, that corner is the last slot.
Fix
Detect the extension, widen the input by 40px so the badge follows it out, and clip those 40px away so nothing visibly moves. Full write-up, with a simulator.

…and then the badge isn't clickable

Extensions
Symptom
The badge appears in the right place but ignores clicks.
Cause
The container is pointer-events: none, and the extension injects its badge as a child of that subtree — inheriting the block.
Fix
One rule, targeting whatever ends up next to the input.
[data-input-otp] + * { pointer-events: all !important; }

Chasing the badge stole focus

Extensions
Symptom
onBlur fired without the user doing anything — breaking validation that runs on blur.
Cause
An earlier version re-focused the input after the badge appeared, to re-run detection. The round trip produced a real blur event.
Fix
The auto-re-focus was removed in 1.4.0. The trade is explicit: if a badge appears and steals focus, the user clicks back in. A phantom blur was judged worse than a manual re-focus.
// Removed in 1.4.0:
// re-focusing the input after a badge appeared fired a blur the user
// never asked for. The auto-re-focus was cut; if a badge steals focus,
// the user clicks back in.

The gutter is reserved for everyone

Extensions
Symptom
With no extension installed, focusing the field still sets input.style.width to calc(100% + 40px).
Cause
The fallback probe bails out only when elementFromPoint returns the container. It never does — the invisible input is the one node in the field with pointer-events: all, so it is always the topmost element at the probe point.
Fix
Not released yet: a corrected probe — compare against the input, and treat a null hit as “learned nothing” rather than as a badge — is staged in PR #118. Nothing visibly changes either way: the clip-path hides the extra width and clips hit-testing with it. To drop the reservation today, set pushPasswordManagerStrategy="none". How detection works.

State and lifecycle#

The browser restored a value before React hydrated

Symptom
Reload a page mid-flow (or navigate back) and the slots are empty while the input holds a value.
Cause
Browsers restore form state before hydration. React's initial state says ""; the DOM says otherwise.
Fix
On mount, compare the input's value against the initial value and adopt the DOM's version.
// The browser may have restored a value into the input before React
// hydrated. Adopt it instead of clobbering it.
if (initialLoadRef.current.value !== input.value) {
  initialLoadRef.current.onChange(input.value)
}

onComplete fired twice

Symptom
The verification request is sent two or three times.
Cause
Firing whenever value.length === maxLength re-fires on every unrelated re-render while the code is full.
Fix
Fire on the transition: the previous value must have been shorter than maxLength and the new one exactly maxLength. Editing a complete code and refilling it fires again, correctly.
if (
  value !== previousValue &&
  previousValue.length < maxLength &&
  value.length === maxLength
) {
  onComplete?.(value)
}

Focusing the field put the caret past the end

Symptom
Tab into a full code and no slot is highlighted, because the caret is at index maxLength.
Cause
The default focus behaviour places the caret at the end of the value, which for a full code is one past the last slot.
Fix
Clamp the start to maxLength - 1 on focus, so a full code lands with its last slot selected and a partial one lands in insert mode.
const start = Math.min(input.value.length, maxLength - 1)
const end = input.value.length
input.setSelectionRange(start, end)

A rejected paste silently loses valid characters

Symptom
With a digits-only pattern, pasting 123-456 does nothing at all — not even 123456.
Cause
The pattern is tested against the whole prospective value, and a failure discards the entire change. It is a gate, not a filter — which is the correct behaviour for typing, and surprising for pasting.
Fix
pasteTransformer rewrites the clipboard text before validation sees it. It also means your pattern must accept partial values — ^\d+$, never ^\d{6}$.
const newValue = e.currentTarget.value.slice(0, maxLength)
if (newValue.length > 0 && regexp && !regexp.test(newValue)) {
  e.preventDefault()
  return // the whole change is dropped
}

Digits-only was the default

Symptom
Before 1.4.0, alphanumeric codes couldn't be typed or pasted, with no indication why. Worst on mobile, where the keyboard also defaulted to a keypad.
Cause
pattern defaulted to REGEXP_ONLY_DIGITS, so every letter was rejected by a rule the developer never wrote.
Fix
The default was removed. Nothing is restricted unless you set pattern — and if you do restrict to letters, set inputMode="text" too.

Progressive enhancement#

No JavaScript means no visible field

No JS
Symptom
With the bundle blocked or still loading, the page shows empty slot outlines and a field nobody can see or use.
Cause
The slots are server-rendered markup, but the input is only invisible because of styles that assume a script will drive it.
Fix
Render a <noscript> stylesheet that restores the input to a plain visible text box, with an opaque background so it covers the inert slots. It is placed first in the output, and it is <noscript> rather than the scripting media query because noscript is honoured during the initial parse — exactly when the bundle hasn't arrived.

What isn't solved#

In the interest of honesty, the known limits:

Badges wider than 40px. The reserved gutter is a constant. An extension with a larger badge, or one anchored further in than 18px, will still overlap — override the width on [data-input-otp] yourself.

Unknown extensions are detected by what they paint. The fallback probe asks what sits at one point in the corner. Anything the user happens to have overlapping that point counts as a badge, and a badge drawn anywhere else does not — details.

A tight container skips the push. The space check walks up to the nearest ancestor that constrains horizontal overflow, and the gutter is only reserved when the full 40px fit. When they don't, the badge stays over the last slot — the same rendering as pushPasswordManagerStrategy="none".

The iOS path can't be tested headlessly. The @supports (-webkit-touch-callout: none) guard never matches in a headless engine, Playwright's WebKit included. Every change to iOS behaviour needs a real device.

Wrapped slot rows read badly. The selection is one continuous range; a row that wraps onto two lines makes a multi-slot selection look like two disconnected fragments.