Build a Robust React OTP Input Verification Component
Build an accessible, mobile-friendly React OTP verification component with great UX, paste handling, and security—complete with TypeScript code.
Image used for representation purposes only.
Overview
One‑time passwords (OTPs) are a common second factor for signing in, confirming sensitive actions, or completing payments. A great OTP input component improves completion rates, reduces user errors, and stays accessible across devices. In this article, you’ll build a production‑grade React OTP input verification component with strong UX, robust keyboard/paste behavior, accessibility, and security considerations.
UX principles for OTP inputs
An effective OTP component should:
- Keep users in flow: auto‑advance focus as digits are typed; backspace moves left.
- Be forgiving: accept pastes with spaces or hyphens; ignore non‑digits for numeric codes.
- Be mobile‑friendly: expose a numeric keypad with inputMode=“numeric” and pattern.
- Be accessible: work with screen readers, keyboard only, and high‑contrast modes.
- Communicate clearly: show error/valid states and provide helpful labels.
- Respect privacy and security: don’t log codes, and consider masking based on risk.
Core requirements
We’ll target a 6‑digit numeric OTP, but make the component configurable:
- length: number of characters (default 6)
- numeric‑only vs alphanumeric
- onChange and onComplete callbacks
- disabled and error states
- paste handling for multi‑character input
- full keyboard support (arrows, home/end, backspace/delete)
- accessibility via ARIA
Component API design
We’ll build a controlled component to keep state predictable in forms and to allow validation upstream.
Props:
- value: string (controlled value)
- onChange(value: string): void
- onComplete?(value: string): void (fires when all slots are filled)
- length?: number (default 6)
- onlyDigits?: boolean (default true)
- autoFocus?: boolean
- disabled?: boolean
- isInvalid?: boolean
- mask?: boolean (use type=password)
- className?: string (container)
- inputClassName?: string (individual slots)
- ariaLabel?: string (group label)
Implementation
Below is a TypeScript React component with robust behavior and accessibility.
// OTPInput.tsx
import React from 'react';
type OTPInputProps = {
value: string;
onChange: (value: string) => void;
onComplete?: (value: string) => void;
length?: number;
onlyDigits?: boolean;
autoFocus?: boolean;
disabled?: boolean;
isInvalid?: boolean;
mask?: boolean;
className?: string;
inputClassName?: string;
ariaLabel?: string;
};
export const OTPInput: React.FC<OTPInputProps> = ({
value,
onChange,
onComplete,
length = 6,
onlyDigits = true,
autoFocus,
disabled,
isInvalid,
mask,
className,
inputClassName,
ariaLabel = 'One-time code',
}) => {
const inputsRef = React.useRef<Array<HTMLInputElement | null>>([]);
// Normalize external value to desired length
const normalized = (value ?? '').slice(0, length);
const setAt = (i: number, ch: string) => {
const arr = normalized.split('');
arr[i] = ch;
const next = arr.join('').padEnd(length, '');
onChange(next);
};
const sanitize = (text: string) => {
const trimmed = text.trim();
if (onlyDigits) return trimmed.replace(/\D+/g, '');
return trimmed.replace(/\s+/g, '');
};
const focusAt = (i: number) => {
const el = inputsRef.current[i];
if (el) el.focus();
};
const handleChange = (i: number) => (e: React.ChangeEvent<HTMLInputElement>) => {
const raw = e.target.value;
const clean = sanitize(raw);
// Multi-character input (e.g., paste into a single box)
if (clean.length > 1) {
distributeFrom(i, clean);
return;
}
const ch = clean.slice(0, 1);
setAt(i, ch);
if (ch && i < length - 1) {
focusAt(i + 1);
}
};
const distributeFrom = (start: number, text: string) => {
const clean = sanitize(text).slice(0, length - start);
if (!clean) return;
const arr = normalized.split('');
for (let k = 0; k < clean.length; k++) {
arr[start + k] = clean[k];
}
const next = arr.join('').slice(0, length);
onChange(next);
const finalIndex = Math.min(start + clean.length, length - 1);
focusAt(finalIndex);
};
const handleKeyDown = (i: number) => (e: React.KeyboardEvent<HTMLInputElement>) => {
const key = e.key;
if (key === 'Backspace') {
if (!normalized[i]) {
// move left if current empty
if (i > 0) {
setAt(i - 1, '');
focusAt(i - 1);
}
} else {
// clear current
setAt(i, '');
}
e.preventDefault();
return;
}
if (key === 'Delete') {
setAt(i, '');
e.preventDefault();
return;
}
if (key === 'ArrowLeft') {
if (i > 0) focusAt(i - 1);
e.preventDefault();
return;
}
if (key === 'ArrowRight') {
if (i < length - 1) focusAt(i + 1);
e.preventDefault();
return;
}
if (key === 'Home') {
focusAt(0);
e.preventDefault();
return;
}
if (key === 'End') {
focusAt(length - 1);
e.preventDefault();
return;
}
};
const handlePaste = (i: number) => async (e: React.ClipboardEvent<HTMLInputElement>) => {
const text = e.clipboardData.getData('text');
if (!text) return;
e.preventDefault();
distributeFrom(i, text);
};
React.useEffect(() => {
const filled = normalized.length === length && !normalized.includes('');
if (filled && onComplete) onComplete(normalized);
}, [normalized, length, onComplete]);
return (
<div
className={className}
role="group"
aria-label={ariaLabel}
aria-invalid={isInvalid || undefined}
aria-disabled={disabled || undefined}
>
{Array.from({ length }).map((_, i) => {
const val = normalized[i] ?? '';
return (
<input
key={i}
ref={(el) => (inputsRef.current[i] = el)}
type={mask ? 'password' : 'text'}
inputMode={onlyDigits ? 'numeric' : 'text'}
pattern={onlyDigits ? '[0-9]*' : undefined}
autoComplete="one-time-code"
name={i === 0 ? 'one-time-code' : undefined}
aria-label={`Digit ${i + 1} of ${length}`}
className={inputClassName}
value={val}
onChange={handleChange(i)}
onKeyDown={handleKeyDown(i)}
onPaste={handlePaste(i)}
disabled={disabled}
autoFocus={autoFocus && i === 0}
maxLength={1}
// Prevent mobile auto-correct
autoCorrect="off"
// Better enter key hint on mobile
enterKeyHint={i === length - 1 ? 'done' : 'next'}
/>
);
})}
</div>
);
};
Notes:
- We use type=“text” with inputMode=“numeric” to avoid number spinners and retain better control.
- autoComplete=“one-time-code” and name=“one-time-code” help mobile systems (especially iOS) suggest codes from SMS.
- The group role and per‑digit aria‑labels make the widget understandable to assistive tech.
Usage example: verify an OTP
// VerifyCodeForm.tsx
import React from 'react';
import { OTPInput } from './OTPInput';
export function VerifyCodeForm() {
const [code, setCode] = React.useState(''.padEnd(6, ''));
const [status, setStatus] = React.useState<'idle' | 'submitting' | 'error' | 'success'>('idle');
const [error, setError] = React.useState<string | null>(null);
const [timer, setTimer] = React.useState(60); // resend cooldown in seconds
React.useEffect(() => {
const id = setInterval(() => setTimer((t) => (t > 0 ? t - 1 : 0)), 1000);
return () => clearInterval(id);
}, []);
async function submit() {
setStatus('submitting');
setError(null);
try {
// Replace with your API. Never log OTPs.
const res = await fetch('/api/verify-otp', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include', // send cookies/CSRF tokens when appropriate
body: JSON.stringify({ code: code.replace(/\s/g, '') }),
});
if (!res.ok) throw new Error('Invalid or expired code');
setStatus('success');
} catch (e: any) {
setStatus('error');
setError(e.message);
}
}
return (
<form
onSubmit={(e) => {
e.preventDefault();
submit();
}}
aria-describedby="otp-help"
>
<label htmlFor="otp-0" className="block mb-2">Enter the 6‑digit code</label>
<OTPInput
value={code}
onChange={setCode}
onComplete={() => submit()}
isInvalid={status === 'error'}
className="flex gap-2"
inputClassName="w-12 h-12 text-center text-xl border rounded focus:outline-none focus:ring"
autoFocus
/>
<p id="otp-help" className="text-sm text-gray-500 mt-2">We sent a code to your email/phone. It expires in 5 minutes.</p>
{status === 'error' && <div role="alert" className="text-red-600 mt-2">{error}</div>}
<button type="submit" disabled={status === 'submitting'} className="btn btn-primary mt-4">
{status === 'submitting' ? 'Verifying…' : 'Verify'}
</button>
<button
type="button"
className="btn btn-link mt-2"
onClick={() => setTimer(60)}
disabled={timer > 0}
aria-disabled={timer > 0}
>
{timer > 0 ? `Resend in ${timer}s` : 'Resend code'}
</button>
</form>
);
}
Styling tips
- Use monospaced digits (font-variant-numeric: tabular-nums) to avoid jitter.
- Provide clear focus states with a visible outline or ring.
- For error states, use both color and shape (e.g., red border plus icon) for accessibility.
- Respect user preference: match prefers-color-scheme for dark mode.
Example minimal CSS:
.otp-slot { width: 3rem; height: 3rem; text-align: center; font-size: 1.25rem; }
.otp-slot:focus { outline: 2px solid #2563eb; outline-offset: 2px; }
.otp-slot[aria-invalid="true"] { border-color: #dc2626; }
Accessibility checklist
- Group container has role=“group” and an informative aria-label.
- Each input has aria-label like “Digit 1 of 6”.
- Keyboard: arrow keys navigate; backspace/delete behave as expected.
- High contrast: borders and focus rings pass contrast guidelines.
- Error messaging uses role=“alert” and is programmatically associated.
Security and privacy considerations
- Never log or analytics‑capture the OTP value.
- Apply rate limiting, lockouts, and short TTLs server‑side.
- Consider masking digits (mask=true) in high‑risk contexts; otherwise, keep them visible to reduce user errors.
- Clear the code and inputs on sign‑out or after verification attempts exceed a threshold.
- Use HTTPS everywhere and include CSRF protections for the verification endpoint.
Edge cases and hardening
- Pasting formats: Users may paste “123 456” or “123‑456”; sanitize to digits.
- Composition events (IME): For numeric OTPs this is rare, but avoid interfering with composition by keeping input type=“text”.
- RTL support: When localizing, ensure visual order and aria labels reflect the correct reading direction.
- Mixed input devices: Tab order must remain natural; maxLength=1 avoids multi‑character overflow.
- Disabled state: Ensure aria-disabled and disabled are in sync, and styles communicate the state.
Testing strategy
- Unit tests: sanitize(), distributeFrom(), keyboard handlers, onComplete firing.
- Component tests: focus movement on input/backspace/arrow keys; paste behaviors; disabled and error states.
- Accessibility tests: role and aria attributes; focus trapping not occurring; screen reader labels present.
- Cross‑browser: iOS Safari (numeric keypad, one‑time‑code suggestion), Android Chrome, desktop browsers.
Advanced: WebOTP API (progressive enhancement)
If you control the SMS format, Chrome on Android can auto‑read the OTP from a specially formatted SMS via the WebOTP API. Use it as a progressive enhancement and always provide a manual path.
// Hook to try WebOTP when supported
export function useWebOTP(enabled: boolean, onFill: (code: string) => void) {
React.useEffect(() => {
if (!enabled || typeof window === 'undefined') return;
// @ts-ignore: WebOTP types are not in lib.dom.d.ts everywhere
if (!('OTPCredential' in window) || !navigator.credentials) return;
const ac = new AbortController();
(async () => {
try {
// @ts-ignore
const cred: any = await navigator.credentials.get({
otp: { transport: ['sms'] },
signal: ac.signal,
});
if (cred && cred.code) onFill(String(cred.code));
} catch {
// silently ignore (permission denied, timeout, etc.)
}
})();
return () => ac.abort();
}, [enabled, onFill]);
}
Use it in your form:
const [code, setCode] = React.useState(''.padEnd(6, ''));
useWebOTP(true, (c) => setCode(c.padEnd(6, '')));
Important: Always include name=“one-time-code” on one of the inputs and keep manual entry available.
Integrating with form libraries
- React Hook Form: Register a hidden input bound to the OTP string, or use Controller to wrap OTPInput.
- Formik: Keep OTP value in Formik state and pass formik.values.otp / setFieldValue.
Example (React Hook Form):
import { useForm, Controller } from 'react-hook-form';
function OtpWithRHF() {
const { control, handleSubmit } = useForm({ defaultValues: { otp: ''.padEnd(6, '') } });
return (
<form onSubmit={handleSubmit((d) => console.log('submit', d))}>
<Controller
control={control}
name="otp"
render={({ field, fieldState }) => (
<OTPInput
value={field.value}
onChange={field.onChange}
onComplete={() => { /* optionally submit */ }}
isInvalid={!!fieldState.error}
className="flex gap-2"
inputClassName="otp-slot border rounded"
/>
)}
/>
<button type="submit">Verify</button>
</form>
);
}
Performance notes
- The component renders a small fixed number of inputs (typically 4–8), so controlled mode is perfectly fine.
- Avoid unnecessary re‑creation of handlers by memoizing if profiling shows hotspots; the sample above is usually sufficient.
Final checklist
- Auto‑advance, backspace, arrow key navigation
- Paste distribution with sanitization
- Numeric keypad on mobile
- Accessible labels and error messaging
- onComplete fires when filled
- Secure handling of OTP (no logging)
- Optional WebOTP enhancement
Conclusion
With clear UX rules, careful event handling, and accessibility baked in, your React OTP input can be both delightful and dependable. Use the component above as a foundation, then adapt styling, masking, and length to your product’s needs. The small details—like forgiving paste behavior and crisp focus management—make a big difference in verification success rates and overall trust.
Related Posts
Building an Accessible React Split Button Dropdown in React + TypeScript
Build an accessible React split button dropdown with ARIA, keyboard support, and TypeScript—complete code, styling, and testing tips.
Mastering Typewriter Text Animation in React: From CSS to Production‑Ready Hooks
Build smooth, accessible typewriter animations in React—learn CSS-only, a reusable hook, deletion/looping, SSR tips, and performance best practices.
Build a Polished React Animated Counter in React (Hook + Component, A11y-First)
Build an accessible React animated counter with a reusable hook and component—easing, formatting, reduced motion, viewport start, and full control.