Most of the code in this library exists because of the things on this page. A single invisible input is a clean idea on a desktop browser; on a phone it runs into a decade of platform behaviour that assumes inputs are visible.
SMS autofill#
The payoff for keeping one input. autocomplete="one-time-code" is set by default, and both iOS and Android will offer the code from the incoming message — dropping all six characters in at once.
// Set for you unless you override it.
<input autocomplete="one-time-code" />- This only works on a single field. Split the code across six inputs and the platform has nowhere to put it — the reason the six-input pattern feels worse on a phone than in a desktop browser.
- Overriding
autoCompletewith anything else turns it off. - The value arrives as one change, so
onCompletefires exactly once — which is what makes auto-submit feel instant.
Getting the message right#
Autofill depends as much on your SMS copy as on your markup. iOS looks for a recognisable code near a keyword; Android's SMS Retriever API wants the @domain #code footer:
Your verification code is 123456
@example.com #123456iOS#
iOS Safari is the hardest surface, and it needs three separate accommodations.
The input cannot be invisible#
iOS refuses to show the long-press Paste menu on an input with opacity: 0 — which would be the obvious way to hide the field. So the input keeps opacity: 1 and is hidden by making its color, caret-color, background and ::selection transparent instead. Every one of those has to be transparent independently, which is why the injected stylesheet exists at all.
The selection paints in a native layer#
iOS draws the selection highlight and the caret in a layer of its own — one that ignores ::selection, CSS opacity and ancestor clipping. That is why, up to 1.4.x, a thin caret-tall line could show through the invisible input whenever a range was selected. What that native layer does respect is the rendered text geometry, so since 1.5.0-beta.1 an iOS-only block rewrites it:
@supports (-webkit-touch-callout: none) {
[data-input-otp] {
font-size: 16px !important; /* the iOS focus-zoom threshold */
width: 1000% !important; /* enlarge the layout box 10x… */
height: 1000% !important;
transform: scale(0.1) !important; /* …and paint it at 1/10th, so the
tap area still matches the container */
transform-origin: 0 0 !important;
letter-spacing: -.6em !important; /* collapse the per-char pitch */
text-indent: -9999px !important; /* park the text offscreen */
left: -1px !important; /* nudge, then compensate */
right: 1px !important;
}
}The text is parked offscreen with text-indent, so at rest there is nothing for the native layer to paint — no artifact, at any fill state or selection size. The scale(0.1) pair shrinks the rendered text (and with it the painted highlight, which iOS floors at roughly 2×2px) while the enlarged layout box keeps the tap area exactly matching the container, and the computed font-size stays at 16px so focusing the field never zooms the page.
The copy/paste menu still works because it only needs an on-screen caret rect during a gesture: on pointerdown the library reveals the text at the fingertip's position (an inline text-indent beats the stylesheet's -9999px), and hides it again on typing, blur or scroll — at most a ~2px fleck under the finger while the gesture is active.
Detection is the same @supports query, read from JavaScript — there is no more reliable iOS signal that doesn't involve sniffing the user agent:
// The same @supports query, from JS — there is no better iOS signal.
const isIOS =
typeof window !== 'undefined' &&
window?.CSS?.supports?.('-webkit-touch-callout', 'none')Paste has to be handled by hand#
Even with the menu showing, letting the browser perform the insertion on iOS produces the wrong value. So on iOS — and on every platform once you pass pasteTransformer — the library takes the paste over: it reads clipboardData, calls preventDefault(), splices the text in at the caret (replacing the selection if there is one), truncates to maxLength, checks the pattern, and restores the selection itself.
That restoration is the part worth knowing about: after a paste the caret is placed at min(newValue.length, maxLength - 1) through newValue.length — so a full code leaves the last slot selected rather than leaving the caret past the end.
Android#
Android is mostly unremarkable, which is the benefit of using a real input. Two things to get right:
// numeric codes — a keypad, no letters
<OTPInput maxLength={6} inputMode="numeric" pattern={REGEXP_ONLY_DIGITS} />
// alphanumeric codes — the full keyboard
<OTPInput maxLength={6} inputMode="text" pattern={REGEXP_ONLY_DIGITS_AND_CHARS} />
// avoid: 'tel' adds *, # and pause characters your pattern will reject
<OTPInput maxLength={6} inputMode="tel" />- Pick the keyboard deliberately.
inputModedefaults tonumeric. An alphanumeric field that forgets to change it hands mobile users a keypad with no letters on it. - Expect autocorrect and prediction. Add
autoCorrect="off"andspellCheck={false}for alphanumeric codes; some keyboards will otherwise try to be helpful about a six-letter "word". - The clipboard bar, the text-selection handles and the magnifier all work, because they operate on the input's selection — the same selection the library mirrors.
Text alignment#
textAlign is a mobile prop wearing a typography prop's name. It does not move your slots. What it changes is where the invisible text — and therefore the native caret, the selection band and the iOS long-press bubble — sits inside the field.
The default, left, is the recommendation. center looks tidier if you ever reveal the input, but it changes which slot a tap lands on: the browser resolves a tap to the nearest character position, and with centred text those positions no longer line up with the slots the user is aiming at.
Autofill styling#
Browsers paint autofilled fields with their own background — famously a pale yellow — using rules that beat almost anything you write. On a field that is supposed to be transparent, that lights up as a coloured rectangle over your slots. Two defences, both needed:
[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;
}And because some browsers keep the :autofill state until the next real input event, one is dispatched:
// Some browsers keep the :autofill state (and its yellow background) until
// the next real input event. So dispatch one.
inputRef.current?.dispatchEvent(new Event('input'))That dispatch is fired from a small helper that runs the same callback at 0ms, 10ms and 50ms — a pragmatic answer to the fact that different browsers settle their autofill and selection state at different moments, and there is no event that reliably marks "done".
Without JavaScript#
A transparent input with no script to drive it is an invisible, unusable field. So the component renders a <noscript> stylesheet — first in the output, before the field itself — that turns the input back into a plain, visible, perfectly usable text box.
Your slots stay in the page (they are server-rendered markup), so the fallback deliberately gives the input an opaque background: it covers them rather than fighting them for the same space.
<OTPInput
maxLength={6}
noScriptCSSFallback={`
[data-input-otp] {
background: white !important;
color: black !important;
caret-color: black !important;
letter-spacing: .25em !important;
text-align: center !important;
border: 1px solid black !important;
border-radius: 4px !important;
width: 100% !important;
}
`}
/>- Pass your own CSS string to match your design system.
- Pass
nullto remove the fallback. Not recommended — it means a progressive-enhancement failure leaves users staring at an empty field. - It is a
<noscript>tag rather than thescriptingCSS media query on purpose:noscriptis honoured during the initial parse, which is exactly when the bundle hasn't arrived.
Testing across platforms#
- Playwright covers the core. Typing, selection, deletion, paste and the slot derivation are all testable headlessly, across Chromium, Firefox and WebKit.
- The iOS branch is not. The
@supportsguard never matches in a headless engine. - Nor is SMS autofill, nor password manager badges. Both need a real device or a real browser extension — which is what the simulator is for.