OTP Input in React: 6-Digit Code Entry With Auto-Focus and Paste
Build a production-ready 6-digit OTP input in React with auto-focus, backspace handling, and clipboard paste support — no libraries needed.
Why OTP Inputs Are Surprisingly Tricky
OTP inputs look simple. Six boxes, one digit each. How hard can it be? Turns out, pretty hard — because you're fighting browser autofill, clipboard events, mobile keyboard behavior, and screen reader semantics all at once, and getting any one of those wrong produces an experience that makes users give up and request a new code.
The naive approach is six separate <input type="text" maxLength={1} /> elements wired together. That works at about 70% fidelity. The remaining 30% is where it gets interesting: paste from iOS lock screen doesn't trigger onChange, Android number keyboards sometimes paste the full string into the first field, and pressing Backspace on an empty field should refocus the previous input — not silently do nothing. Honestly, most OTP implementations in the wild get at least two of these wrong.
In practice, you have two realistic paths: reach for a library like input-otp (which underpins shadcn/ui's OTP primitive as of 2024) or build it yourself with a single <input> and a styled overlay. Both are valid. This article walks through the from-scratch approach so you actually understand what's happening, then shows you the library shortcut if you just need to ship.
The Single-Input Architecture (Recommended)
The smartest OTP implementation uses one real `<input>` element positioned off-screen or made transparent, overlaid by six purely visual <div> boxes. The browser sees a single input — so autofill, paste, and accessibility all work correctly out of the box. You handle rendering. This is exactly how input-otp works under the hood.
Here's the mental model: the input sits at opacity: 0 or position: absolute; left: -9999px, accepts keyboard events normally, and you read input.value to drive your six visual slots. When value.length changes, re-render the slots. When focus changes, show the caret on the active slot. That's it.
import { useState, useRef, useCallback } from 'react';
interface OTPInputProps {
length?: number;
onComplete?: (code: string) => void;
}
export function OTPInput({ length = 6, onComplete }: OTPInputProps) {
const [value, setValue] = useState('');
const [focused, setFocused] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const raw = e.target.value.replace(/\D/g, '').slice(0, length);
setValue(raw);
if (raw.length === length) onComplete?.(raw);
},
[length, onComplete]
);
const activeIndex = Math.min(value.length, length - 1);
return (
<div
className="relative flex gap-3 cursor-text"
onClick={() => inputRef.current?.focus()}
>
{/* Hidden real input */}
<input
ref={inputRef}
value={value}
onChange={handleChange}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
inputMode="numeric"
autoComplete="one-time-code"
className="absolute opacity-0 w-0 h-0"
aria-label="One-time password"
/>
{/* Visual slots */}
{Array.from({ length }).map((_, i) => (
<div
key={i}
className={[
'w-12 h-14 flex items-center justify-center',
'rounded-xl border-2 text-xl font-mono font-semibold',
'transition-colors duration-150',
focused && i === activeIndex
? 'border-violet-500 bg-violet-50 dark:bg-violet-950'
: 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900',
].join(' ')}
>
{value[i] ?? ''}
{focused && i === activeIndex && (
<span className="animate-pulse text-violet-500 ml-px">|</span>
)}
</div>
))}
</div>
);
}Notice autoComplete="one-time-code" on the input — that's the attribute that makes iOS SMS autofill work in Safari 14+. Without it, the yellow autofill banner still appears but the value doesn't populate automatically. Worth noting: inputMode="numeric" gives you the numeric keyboard on mobile without restricting the input type, which matters because type="number" breaks paste and e.target.value behavior in ways you don't want.
The .replace(/\D/g, '') strip is defensive — it handles SMS autofill on some Android devices that prefixes the code with text like "G-" before the digits. Stripping non-digits first means your onComplete always receives a clean numeric string.
The Six-Inputs Approach: When You Need It
Sometimes you're locked into a design system that styles individual inputs, or you need each slot to have its own border-radius, gradient, or animation. In that case, six separate inputs is the only path — and you need to wire them up carefully. The key events to handle are onChange (auto-advance on fill), onKeyDown (Backspace to go back), and onPaste (distribute pasted content across slots).
import { useRef, useState, ClipboardEvent, KeyboardEvent } from 'react';
export function OTPSixInputs({ onComplete }: { onComplete?: (v: string) => void }) {
const [digits, setDigits] = useState(Array(6).fill(''));
const refs = useRef<(HTMLInputElement | null)[]>([]);
const focus = (i: number) => refs.current[i]?.focus();
const handleChange = (i: number, val: string) => {
const digit = val.replace(/\D/g, '').slice(-1);
const next = [...digits];
next[i] = digit;
setDigits(next);
if (digit && i < 5) focus(i + 1);
if (next.every(Boolean)) onComplete?.(next.join(''));
};
const handleKeyDown = (i: number, e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Backspace') {
if (digits[i]) {
const next = [...digits];
next[i] = '';
setDigits(next);
} else if (i > 0) {
focus(i - 1);
}
}
if (e.key === 'ArrowLeft' && i > 0) focus(i - 1);
if (e.key === 'ArrowRight' && i < 5) focus(i + 1);
};
const handlePaste = (e: ClipboardEvent) => {
e.preventDefault();
const pasted = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, 6);
const next = [...digits];
pasted.split('').forEach((ch, idx) => { next[idx] = ch; });
setDigits(next);
focus(Math.min(pasted.length, 5));
if (pasted.length === 6) onComplete?.(pasted);
};
return (
<div className="flex gap-3" onPaste={handlePaste}>
{digits.map((d, i) => (
<input
key={i}
ref={(el) => { refs.current[i] = el; }}
value={d}
onChange={(e) => handleChange(i, e.target.value)}
onKeyDown={(e) => handleKeyDown(i, e)}
inputMode="numeric"
maxLength={1}
className="w-12 h-14 text-center text-xl font-mono rounded-xl border-2
border-gray-200 focus:border-violet-500 focus:outline-none
dark:border-gray-700 dark:bg-gray-900 transition-colors"
aria-label={`Digit ${i + 1} of 6`}
/>
))}
</div>
);
}The onPaste handler at the container level rather than each individual input is the trick that makes paste reliable. If you attach it per-input, paste on the first field works but paste on the third field only fills from that position. Container-level paste always distributes from slot 0.
One more thing — the handleKeyDown needs to check digits[i] before trying to clear it. If you always clear on Backspace regardless, you get the frustrating UX where the user has to hit Backspace twice to move back: once to clear the empty field (no-op visually) and once to actually go back. The check if (digits[i]) { clear } else { go back } fixes this.
Styling the Active State and Visual Polish
The visual caret in the single-input approach is a <span> with animate-pulse. That's fine for most UIs. But if you want something closer to a native cursor blink at exactly 1s, use a CSS animation directly: @keyframes blink { 0%,100% { opacity: 1 } 50% { opacity: 0 } } with animation: blink 1s step-end infinite. The difference between linear and step-end is significant — step-end gives you the hard on/off toggle that real text cursors use.
For the slot container dimensions, 48px wide × 56px tall (w-12 h-14 in Tailwind) is the sweet spot at 16px base font. Go narrower than 40px and fat fingers start hitting adjacent boxes on mobile. Go taller than 64px and it starts looking like a calculator keypad rather than a code input.
If you're building on top of Empire UI's design system, you can pull the border and focus-ring tokens directly from the same palette that powers the glassmorphism components. A frosted-glass OTP card — backdrop-blur-md container, semi-transparent slot backgrounds — looks genuinely good on authentication screens with gradient backgrounds. Pair it with the glassmorphism generator to dial in the exact blur and opacity before you hardcode values.
Quick aside: if you want the filled slots to animate in — a subtle scale-up on digit entry — wrap each slot's content in a key={digit} span so React remounts it on change, then apply animate-in zoom-in-50 duration-150 (Tailwind Animate). It's a small touch but it makes the input feel responsive in a way that raw state updates don't.
Accessibility, Validation, and Edge Cases
Screen readers need context. A row of six unlabeled inputs is confusing. The six-input approach needs individual aria-label="Digit N of 6" on each input. The single-input approach needs one aria-label="One-time password" on the hidden input and aria-hidden="true" on all the visual slots — otherwise VoiceOver reads both the real input and all six decorative divs.
Validation timing matters a lot for UX. Don't show an error until onComplete fires AND the server responds. Showing an inline error the moment the sixth digit is typed — before the request even goes out — is jarring. The pattern that works well: set loading: true in onComplete, await your API call, then set either success: true or error: string. Disable the input while loading so users don't type over an in-flight request.
function AuthScreen() {
const [status, setStatus] = useState<'idle'|'loading'|'error'|'success'>('idle');
const [errorMsg, setErrorMsg] = useState('');
const handleComplete = async (code: string) => {
setStatus('loading');
setErrorMsg('');
try {
await verifyOTP(code); // your API call
setStatus('success');
} catch (err) {
setStatus('error');
setErrorMsg('Invalid code. Check your messages and try again.');
}
};
return (
<div>
<OTPInput onComplete={handleComplete} />
{status === 'loading' && <p className="text-sm text-gray-500 mt-3">Verifying...</p>}
{status === 'error' && <p className="text-sm text-red-500 mt-3">{errorMsg}</p>}
{status === 'success' && <p className="text-sm text-green-500 mt-3">Verified!</p>}
</div>
);
}One edge case that bites people on iOS 17+: when Face ID triggers SMS autofill, it populates the value and fires both onChange and input events in rapid succession. If your onComplete makes a network call, you can end up with two in-flight requests for the same code. A simple ref-based guard — const called = useRef(false) set to true on first onComplete trigger — prevents the double-submit. Reset it if you reset the input value.
Look, the resend timer is also part of the UX your component needs to own. A 30-second countdown before the "Resend code" button activates is standard. Don't just disable the button with no feedback — show Resend in 28s updating every second. It's the difference between a user waiting patiently and a user opening a support ticket.
Using the `input-otp` Library (The Fast Path)
If you're building with shadcn/ui or just want battle-tested behavior without rolling your own, input-otp by Guilherme Rodz is the right call. It ships at about 3KB gzipped and handles every edge case covered above plus a few you haven't thought of yet. Install it with npm install input-otp — it's been stable since v1.2.0.
import { OTPInput, OTPInputContext } from 'input-otp';
import { useContext } from 'react';
function Slot({ index }: { index: number }) {
const { slots } = useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = slots[index];
return (
<div
className={`w-12 h-14 flex items-center justify-center rounded-xl border-2
font-mono text-xl transition-colors
${ isActive ? 'border-violet-500' : 'border-gray-200 dark:border-gray-700' }`}
>
{char}
{hasFakeCaret && <span className="animate-caret-blink">|</span>}
</div>
);
}
export function LibraryOTP({ onComplete }: { onComplete?: (v: string) => void }) {
return (
<OTPInput
maxLength={6}
onComplete={onComplete}
containerClassName="flex gap-3"
render={({ slots }) => (
<>
{slots.map((_, i) => <Slot key={i} index={i} />)}
</>
)}
/>
);
}The hasFakeCaret boolean from context is the library telling you "this slot is active and nothing has been typed yet — show a caret." That's the detail most hand-rolled implementations miss: the caret should disappear the moment a character fills the slot, not sit next to it. That small correctness makes the whole thing feel native.
That said, if you need deep visual customization — per-slot gradients, animated borders, glassmorphic backgrounds — the library's render prop gives you full control. You own 100% of the markup. The library handles only the event wiring. It's a clean separation and honestly the approach I'd recommend for any production auth flow you're building today.
For complete UI kits with pre-built auth components already wired up, Empire UI's templates include sign-in screens with OTP flows across multiple visual styles — neumorphic, neobrutalist, glassmorphic. Saves you the layout work and the CSS iteration when you just need to ship.
FAQ
Always type="text" with inputMode="numeric". The type="number" input has broken paste behavior, strips leading zeros, and fires e.target.value as an empty string in some edge cases. Use inputMode="numeric" to get the numeric keyboard on mobile without those problems.
Add autoComplete="one-time-code" to the input element. Safari reads this attribute and links the input to SMS messages containing verification codes. It works from Safari 14 onward and requires no other configuration.
You're probably attaching the onPaste handler to individual inputs instead of the container element. Attach it to the wrapper div so paste fires regardless of which slot is focused, then distribute the clipboard string across all slots from index 0.
Single-input uses one hidden <input> with visual-only slot divs — better accessibility and paste handling by default. Six-input gives you more per-slot styling control but requires manual wiring for paste, Backspace navigation, and screen reader labels.