Validation here splits cleanly in two. pattern decides what is allowed to enter the field at all — a syntax gate, enforced on every keystroke and paste. Whether the code is correct is a question for your server, and belongs in onComplete.
pattern#
Pass a string or a RegExp. Three common ones are exported so you don't have to write them:
It rejects, it doesn't filter#
This is the behaviour to internalise: the pattern is tested against the entire prospective value, and a failure discards the whole change. It never strips the offending characters and keeps the rest.
// Simplified from the change handler:
const newValue = event.currentTarget.value.slice(0, maxLength)
if (newValue.length > 0 && regexp && !regexp.test(newValue)) {
return // the change is dropped whole — nothing is filtered out of it
}
onChange(newValue)Which means, with a digits-only pattern:
- Typing
adoes nothing at all — no flicker, no partial insert. - Pasting
12a456does nothing either. Not12456, not12. Nothing. - So your pattern must accept every intermediate value, not just the finished one. Anchoring with
+rather than a fixed{6}is what makes that work —^\d+$matches1as happily as123456.
Custom patterns#
// Crockford base32: digits and letters, minus I, L, O and U.
<OTPInput maxLength={8} pattern="^[0-9A-HJKMNP-TV-Z]+$" inputMode="text" />
// Or hand it a RegExp — it is used as-is, so flags are yours to choose.
<OTPInput maxLength={6} pattern={/^[0-9a-f]+$/i} />The pattern is also mirrored onto the input's native pattern attribute, so native form validation and :invalid line up with it for free.
Letters, and the keyboard problem#
inputMode defaults to numeric, which on a phone means a keypad with no letters on it. An alphanumeric field that forgets to change this is unusable on mobile — the single most reported issue with codes that aren't purely numeric.
autoCapitalize="characters" nudges mobile keyboards toward caps, but it is a hint, not a guarantee. If your codes are case-insensitive, normalise the value rather than trusting the keyboard:
const [value, setValue] = React.useState('')
<OTPInput
value={value}
onChange={next => setValue(next.toUpperCase())}
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
maxLength={6}
/>Pasting#
Codes arrive from the outside world with punctuation attached — 123-456 from an email, 123 456 from a chat message, a trailing newline from a terminal. Against a strict pattern, every one of those pastes silently does nothing.
pasteTransformer runs on the clipboard text before validation sees it, so the paste that would have been rejected becomes the paste you wanted:
// Strip anything that isn't a digit — hyphens, spaces, invisible characters
// that came along for the ride from an email client.
pasteTransformer={pasted => pasted.replace(/[^0-9]/g, '')}
// Pull the code out of a whole sentence: "Your code is 123456."
pasteTransformer={pasted => pasted.match(/\d{6}/)?.[0] ?? pasted}
// Normalise case for an alphanumeric field.
pasteTransformer={pasted => pasted.trim().toUpperCase()}Verifying the code#
onComplete fires exactly once on the transition into a full value — including when the value arrives all at once from a paste or an SMS autofill, which is precisely when you want it.
<OTPInput
maxLength={6}
pattern={REGEXP_ONLY_DIGITS}
onComplete={async code => {
const result = await verify(code)
if (!result.ok) setError('That code is incorrect.')
}}
/>The order to think in, for a field that also shows errors:
patternkeeps malformed input out.onCompletesubmits the finished code.- Your rejection sets an error state;
aria-invalidandrole="alert"announce it. onChangeclears the error as soon as the user edits, so they aren't shouted at while fixing it.
Forms has that wired up end to end.