DEV Community

WDSEGA
WDSEGA

Posted on

Component Deep Dive #35: OTP Input — Auto-Advance, Paste, and Backspace Chaining

OTP Input | Component Deep Dive #35: OTP Input — Auto-Advance, Paste, and Backspace Chaining

Six boxes, one digit each. Seems too simple to warrant a component — until you handle paste distribution, backspace chaining, and mobile IME quirks.

The OTP (one-time password) input is a staple of authentication flows: SMS codes, email verification, 2FA app codes. Its interaction model is completely different from a regular text input: six independent boxes, auto-advance on input, backspace clears and retreats, paste auto-distributes across all boxes.

Basic Structure

The most common implementation uses six separate <input> elements:

<div class="otp-container" role="group" aria-label="One-time code">
  <input type="text" inputmode="numeric" maxlength="1" data-index="0" aria-label="Digit 1" />
  <input type="text" inputmode="numeric" maxlength="1" data-index="1" aria-label="Digit 2" />
  <input type="text" inputmode="numeric" maxlength="1" data-index="2" aria-label="Digit 3" />
  <input type="text" inputmode="numeric" maxlength="1" data-index="3" aria-label="Digit 4" />
  <input type="text" inputmode="numeric" maxlength="1" data-index="4" aria-label="Digit 5" />
  <input type="text" inputmode="numeric" maxlength="1" data-index="5" aria-label="Digit 6" />
</div>
Enter fullscreen mode Exit fullscreen mode

Key attributes: inputmode="numeric" triggers the numeric keypad on mobile, maxlength="1" limits each box to one character, aria-label provides accessible labels.

Auto-Advance

After entering a digit, auto-focus the next box:

function handleInput(e) {
  const input = e.target;
  const index = parseInt(input.dataset.index);

  const value = input.value.replace(/\D/g, '');
  input.value = value;

  if (value && index < 5) {
    const next = inputs[index + 1];
    next.focus();
    next.select();
  }

  if (value && index === 5) {
    checkComplete();
  }
}
Enter fullscreen mode Exit fullscreen mode

next.select() is important — if the user navigates back to a filled box and types, selecting existing content lets the new input overwrite it instead of appending.

Backspace Chaining

Backspace behavior is the most bug-prone part. Expected behavior: if the current box has a value, clear it; if the current box is empty, clear the previous box and focus it.

function handleKeydown(e) {
  const input = e.target;
  const index = parseInt(input.dataset.index);

  if (e.key === 'Backspace') {
    if (input.value) {
      input.value = '';
      e.preventDefault();
      checkComplete();
    } else if (index > 0) {
      e.preventDefault();
      const prev = inputs[index - 1];
      prev.value = '';
      prev.focus();
      checkComplete();
    }
  }

  if (e.key === 'ArrowLeft' && index > 0) {
    e.preventDefault();
    inputs[index - 1].focus();
  }

  if (e.key === 'ArrowRight' && index < 5) {
    e.preventDefault();
    inputs[index + 1].focus();
  }
}
Enter fullscreen mode Exit fullscreen mode

Without e.preventDefault(), the browser's default behavior may clear the current box's value but leave focus in place — forcing the user to press backspace twice to retreat.

Paste Distribution

When a user copies a code from their SMS app and pastes into the first box, they expect all six digits to auto-distribute:

function handlePaste(e) {
  e.preventDefault();
  const pasted = (e.clipboardData || window.clipboardData).getData('text');
  const digits = pasted.replace(/\D/g, '').slice(0, 6);

  digits.split('').forEach((digit, i) => {
    if (inputs[i]) {
      inputs[i].value = digit;
    }
  });

  const nextEmpty = inputs.findIndex(inp => !inp.value);
  if (nextEmpty === -1) {
    inputs[5].focus();
    checkComplete();
  } else {
    inputs[nextEmpty].focus();
  }
}
Enter fullscreen mode Exit fullscreen mode

Paste distribution is the most important UX optimization for OTP components — without it, users must tap six times. Note the replace(/\D/g, '') filter for non-digit characters, since users may copy "Your code is: 123456" from their SMS.

Mobile Pitfalls

IME Issues: Some Android IMEs behave oddly with maxlength="1" inputs — they don't fire the input event after one character, instead waiting for candidate word selection. The workaround is to use type="tel" (more reliably triggers numeric keypad than type="text") and listen to keyup as a fallback.

Auto-fill: iOS Safari supports SMS OTP autofill (autocomplete="one-time-code"), but requires all OTP inputs inside a <form> with an action attribute. Without a <form> wrapper (common in React SPAs), autofill won't work.

<form action="/verify" method="post">
  <input type="hidden" name="otp" id="otp-hidden" />
  <div class="otp-container">
    <input type="tel" inputmode="numeric" autocomplete="one-time-code" maxlength="1" data-index="0" />
    <!-- ... -->
  </div>
</form>
Enter fullscreen mode Exit fullscreen mode

Focus jumping: On iOS, focus() calls outside a user gesture's synchronous execution stack are blocked by the browser. Auto-advance next.focus() usually works (it's in the input event handler), but calling it in an async callback will fail silently.

Completion Detection

Auto-submit when all six digits are filled:

function checkComplete() {
  const code = inputs.map(inp => inp.value).join('');
  if (code.length === 6) {
    submitOTP(code);
  }
}
Enter fullscreen mode Exit fullscreen mode

Don't trigger submission on every input event — only when length reaches 6. Use a wasComplete flag to prevent duplicate submissions.

Alternative: Single Input

Some implementations use a single <input> overlaid on six visual boxes, using CSS letter-spacing to spread characters apart. The advantage: better paste and IME compatibility (single input). The disadvantage: backspace behavior is harder to customize, and letter-spacing alignment varies across fonts.

Which approach to choose depends on priorities: if mobile compatibility is paramount, use single input; if desktop experience matters most, use six separate inputs.

Top comments (0)