Learn the concept
Forms
An OTP input renders N separate single-character inputs with automatic focus management — advancing on input, reversing on backspace, and handling clipboard paste to distribute digits across all fields.
Core Requirements:
onComplete callback when all fields are filledFocus Management:
Use an array of refs (useRef) to hold references to each input element. On every onChange, check if a value was entered — if so, call nextInput.focus(). On onKeyDown for Backspace, if the current field is empty, focus the previous field.
Paste Handling:
Intercept the onPaste event on the container or individual inputs. Extract the pasted text, split into characters, and set each input's value in sequence. Then focus the first empty field or the last field.
Edge Cases:
inputMode="numeric" and autoComplete="one-time-code" for native OTP autofillAccessibility:
aria-label="Digit N of M" on each inputrole="group" with an aria-labelledby pointing to a visible labelaria-live regionimport { useRef, useState, useCallback } from 'react';
function OTPInput({ length = 6, onComplete }) {
const [values, setValues] = useState(Array(length).fill(''));
const inputRefs = useRef([]);
const focusInput = (index) => {
const clamped = Math.max(0, Math.min(index, length - 1));
inputRefs.current[clamped]?.focus();
};
const handleChange = useCallback((index, e) => {
const char = e.target.value.slice(-1); // Take last character typed
if (char && !/^\d$/.test(char)) return; // Digits only
const next = [...values];
next[index] = char;
setValues(next);
if (char && index < length - 1) {
focusInput(index + 1);
}
const otp = next.join('');
if (otp.length === length && next.every(Boolean)) {
onComplete?.(otp);
}
}, [values, length, onComplete]);
const handleKeyDown = useCallback((index, e) => {
if (e.key === 'Backspace') {
if (!values[index] && index > 0) {
// Current field empty — clear previous and focus it
const next = [...values];
next[index - 1] = '';
setValues(next);
focusInput(index - 1);
} else {
// Clear current field
const next = [...values];
next[index] = '';
setValues(next);
}
} else if (e.key === 'ArrowLeft') {
focusInput(index - 1);
} else if (e.key === 'ArrowRight') {
focusInput(index + 1);
}
}, [values, length]);
const handlePaste = useCallback((e) => {
e.preventDefault();
const pasted = e.clipboardData.getData('text').replace(/\D/g, '');
const chars = pasted.slice(0, length).split('');
const next = [...values];
chars.forEach((ch, i) => { next[i] = ch; });
setValues(next);
focusInput(Math.min(chars.length, length - 1));
if (chars.length === length) {
onComplete?.(next.join(''));
}
}, [values, length, onComplete]);
return (
<div role="group" aria-label="OTP Input" onPaste={handlePaste}
style={{ display: 'flex', gap: 8 }}>
{values.map((val, i) => (
<input
key={i}
ref={(el) => { inputRefs.current[i] = el; }}
type="text"
inputMode="numeric"
autoComplete={i === 0 ? 'one-time-code' : 'off'}
maxLength={1}
value={val}
onChange={(e) => handleChange(i, e)}
onKeyDown={(e) => handleKeyDown(i, e)}
aria-label={`Digit ${i + 1} of ${length}`}
style={{
width: 48, height: 56, textAlign: 'center',
fontSize: 24, border: '2px solid #ccc', borderRadius: 8
}}
/>
))}
</div>
);
}
// Usage
function VerifyPage() {
const handleOTP = (otp) => {
console.log('OTP entered:', otp);
// POST /api/verify-otp { otp }
};
return (
<div>
<h2>Enter verification code</h2>
<OTPInput length={6} onComplete={handleOTP} />
</div>
);
}Login flows where users enter a 6-digit code from an authenticator app or SMS.
E-commerce and food delivery apps like Swiggy send SMS OTPs to verify phone numbers during registration.
Banking and fintech apps require OTP entry to authorize transactions.
Build a complete OTP flow: phone input → send code → OTP entry with countdown timer → resend button → success screen.
Extend the OTP component with error states, shake animation on wrong code, aria-live announcements, and automatic retry limiting.