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 === selectionEndis 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 withsetSelectionRange(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
cborders slotc-1and slotc. 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. ThewasPreviouslyInsertingguard suppresses that shift when leaving insert mode, where indexcalready 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
selectionchangefor 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: transparenthides the glyphs but not the selection highlight, which is painted by::selectionand 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
:autofillpseudo-class carries UA styles that outrank almost anything you write — including a plainbackground: 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
:autofillonly on the next real input event, not when the value changes programmatically. - Fix
- Dispatch a synthetic
inputevent — and do it three times, at0ms,10msand50ms, 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.insertRulethrows 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: 0qualifies. - Fix
- Never use
opacity: 0. The input keepsopacity: 1and hides itself through transparentcolor,caret-color,backgroundand::selectioninstead. This one constraint is why the injected stylesheet exists.
opacity: '1', // Mandatory for iOS hold-pasteThe 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, CSSopacityand 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. Computedfont-sizestays at 16px, below which focusing would zoom the page. During a pointer gesture the text is revealed at the fingertip via an inlinetext-indentso the copy/paste menu can anchor, and hidden again on typing, blur or scroll. Theleft: -1px/right: 1pxpair 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
onPastedirectly: readclipboardData,preventDefault(), splice at the caret (replacing the selection if any), truncate tomaxLength, test the pattern, then restore the selection explicitly so a full paste leaves the last slot selected instead of a caret past the end. PassingpasteTransformerenables 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 undefinedwhen the component renders on a server.- Cause
- iOS detection uses
window.CSS.supports, and there is nowindowin Node — nor aCSSobject 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 inputpointer-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
ResizeObserverpublishes the container's pixel height as--root-height, and the input'sfont-sizeis 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 toforward, 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
onBlurfired 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.widthtocalc(100% + 40px). - Cause
- The fallback probe bails out only when
elementFromPointreturns the container. It never does — the invisible input is the one node in the field withpointer-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
nullhit 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, setpushPasswordManagerStrategy="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 === maxLengthre-fires on every unrelated re-render while the code is full. - Fix
- Fire on the transition: the previous value must have been shorter than
maxLengthand the new one exactlymaxLength. 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 - 1on 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-456does nothing at all — not even123456. - 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
pasteTransformerrewrites 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
patterndefaulted toREGEXP_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, setinputMode="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 thescriptingmedia query becausenoscriptis 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.