ESC

Type to search the knowledge base.

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
  • onComplete when 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)

  1. Value is one string — UI is N boxes
  2. Paste is the make-or-break edge case
  3. Autofill prefers one input — mention hybrid approach
  4. MVP — type + backspace + paste; then a11y labels
  5. 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-describedby on 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 onComplete in render body without guard — use effect with equality

Footguns

  1. Broken paste — only first box fills
  2. maxLength={1} blocking autofill of full code into first input — handle multi-digit in onChange
  3. Calling onComplete every render
  4. Losing focus when parent remounts inputs (unstable keys)
  5. 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.

Further reading