OTP Input Boxes
Machine-coding brief for OTP/PIN inputs — multi-box entry, paste, backspace, a11y one-field semantics, and autofill.
intermediate5 min read
- machine-coding
- interview
- react
- forms
- a11y
Problem statement
Build an OTP input: typically 4–6 boxes for one-time codes. Users type digits, paste full codes, and backspace across boxes. Interviewers score caret management, paste handling, and accessibility (don’t invent six unlabeled inputs without a story).
Requirements
Must have
- N digit boxes (prop
length, default 6) - Accept digits only (or alphanumeric if specified)
- Auto-advance focus on entry
- Backspace moves to previous box when empty
- Paste full code into the group
- Controlled
value: string+onChange onCompletewhen all filled
Should have
autoComplete="one-time-code"for SMS autofill (often one hidden/main field)- Disabled / error / secure (
type="password"masking) modes - Arrow key navigation between boxes
Nice to have
- Single underlying input pattern (better a11y/autofill) with visual boxes
- Resend timer integration (outside component)
Planning (5 minutes out loud)
- Value is one string — UI is N boxes
- Paste is the make-or-break edge case
- Autofill prefers one input — mention hybrid approach
- MVP — type + backspace + paste; then a11y labels
- iOS SMS —
autocomplete="one-time-code"
Architecture
OtpInput
├── Hidden or visible inputs[i]
└── value sync helpers
API
type OtpInputProps = {
length?: number;
value: string;
onChange: (value: string) => void;
onComplete?: (value: string) => void;
disabled?: boolean;
error?: boolean;
id?: string;
"aria-label"?: string;
"aria-labelledby"?: string;
"aria-describedby"?: string;
};
Implementation sketch
function OtpInput({
length = 6,
value,
onChange,
onComplete,
disabled,
error,
}: OtpInputProps) {
const inputsRef = useRef<Array<HTMLInputElement | null>>([]);
const digits = value.padEnd(length, " ").slice(0, length).split("");
function setAt(index: number, char: string) {
const raw = value.padEnd(length, " ");
const next =
raw.slice(0, index) + char + raw.slice(index + 1);
const cleaned = next.replace(/ /g, "").replace(/\D/g, "").slice(0, length);
// rebuild preserving positions more carefully:
const arr = value.split("");
arr[index] = char;
const joined = arr.join("").replace(/\D/g, "").slice(0, length);
// simpler approach: treat value as only filled prefix/string of digits
}
// Cleaner model: value is only the digit string (length 0..N)
function update(next: string) {
const cleaned = next.replace(/\D/g, "").slice(0, length);
onChange(cleaned);
if (cleaned.length === length) onComplete?.(cleaned);
}
function handleChange(index: number, t: string) {
const digit = t.replace(/\D/g, "").slice(-1);
if (!digit) return;
const chars = value.split("");
while (chars.length < index) chars.push("");
chars[index] = digit;
const next = chars.join("").replace(/\D/g, "").slice(0, length);
// better:
const nextVal =
value.slice(0, index) + digit + value.slice(index + 1);
update(
(value.substring(0, index) + digit + value.substring(index + 1))
.replace(/\D/g, "")
.slice(0, length)
);
// focus next
inputsRef.current[index + 1]?.focus();
}
// Refined helpers below...
Production-cleaner version
function OtpInput({
length = 6,
value,
onChange,
onComplete,
disabled,
}: OtpInputProps) {
const refs = useRef<Array<HTMLInputElement | null>>([]);
useEffect(() => {
if (value.length === length) onComplete?.(value);
}, [value, length, onComplete]);
function write(next: string) {
onChange(next.replace(/\D/g, "").slice(0, length));
}
function onInput(i: number, raw: string) {
const digits = raw.replace(/\D/g, "");
if (!digits) {
// cleared
const next = value.slice(0, i) + value.slice(i + 1);
write(next);
return;
}
if (digits.length > 1) {
// multi-digit from autofill on one box
write(value.slice(0, i) + digits);
const focusAt = Math.min(length - 1, i + digits.length);
refs.current[focusAt]?.focus();
return;
}
const next =
value.slice(0, i) + digits + value.slice(i + 1);
write(next);
if (i < length - 1) refs.current[i + 1]?.focus();
}
function onKeyDown(i: number, e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "Backspace") {
e.preventDefault();
if (value[i]) {
write(value.slice(0, i) + value.slice(i + 1));
} else if (i > 0) {
write(value.slice(0, i - 1) + value.slice(i));
refs.current[i - 1]?.focus();
}
}
if (e.key === "ArrowLeft" && i > 0) refs.current[i - 1]?.focus();
if (e.key === "ArrowRight" && i < length - 1) refs.current[i + 1]?.focus();
}
function onPaste(e: React.ClipboardEvent) {
e.preventDefault();
write(e.clipboardData.getData("text"));
refs.current[Math.min(length - 1, e.clipboardData.getData("text").replace(/\D/g, "").length)]?.focus();
}
return (
<div
className="otp"
role="group"
aria-label="One-time code"
onPaste={onPaste}
>
{Array.from({ length }, (_, i) => (
<input
key={i}
ref={(el) => {
refs.current[i] = el;
}}
inputMode="numeric"
pattern="[0-9]*"
autoComplete={i === 0 ? "one-time-code" : "off"}
maxLength={1}
value={value[i] ?? ""}
disabled={disabled}
aria-label={`Digit ${i + 1} of ${length}`}
onChange={(e) => onInput(i, e.target.value)}
onKeyDown={(e) => onKeyDown(i, e)}
/>
))}
</div>
);
}
Accessibility essentials
- Group labeled “One-time passcode”
- Each box: “Digit i of n”
- Error message linked via
aria-describedbyon the group - Prefer also documenting single input approach for SR + autofill reliability
- Don’t use only color for error
Performance notes
- Trivial render cost
- Avoid
onCompletein render body without guard — use effect with equality
Footguns
- Broken paste — only first box fills
- maxLength={1} blocking autofill of full code into first input — handle multi-digit in
onChange - Calling onComplete every render
- Losing focus when parent remounts inputs (unstable keys)
- Non-digit garbage in value
Interview out-loud answer
OTP is one string value rendered as N boxes. Typing writes a digit and advances focus; backspace deletes and may move left; paste fills the string at once. First box gets
autocomplete="one-time-code". I’d mention a single-field pattern as the more robust autofill/a11y alternative if the design allows visually split boxes via CSS.
Related on this site
- Multi Step Wizard
- Accessible Modal Dialog
- Auth Session Design for SPAs
- Machine Coding Interview Framework