Forms & Input

Copy. Paste. Ship.

Home › Code Snippets › HTML › OTP Verification

Part of our complete guide: HTML & Form Snippets →

Form Component · HTML + CSS + JS

OTP Verification Input

Six-digit code input with auto-advance, backspace navigation, paste support, and a resend timer. Copy-paste ready.

Last updated: October 2026 · 8 min read

An OTP input has 6 separate <input> boxes, each accepting one digit. Typing auto-focuses the next; backspace auto-focuses the previous. Pasting a 6-digit code fills all boxes. Use inputmode="numeric" for the mobile number pad.

What you get: Full OTP verification screen with 6-digit autofocus input, paste-to-fill, backspace navigation, resend timer, and success state. Mobile-friendly and screen-reader accessible.

Why OTP inputs are tricky

The OTP screen is one of the highest-friction moments in any authentication flow. Users have just received a code on their phone, they're switching between apps, and they have exactly one shot before the code expires. Every wasted tap is a chance for them to give up and switch to a competitor.

Most OTP inputs fail on the details: you type a digit and the cursor stays in the same box, so you have to tap the next one manually. Or you paste a code and only the first character lands. Or the boxes don't respond to backspace — you have to tap and select the previous box to delete. Each of these seems small, but they add up to a form that "feels wrong" without users knowing why.

The version below handles all of it: typing auto-advances, backspace goes back, paste distributes across all boxes, and a resend countdown prevents spam clicks. It also exposes the code via autocomplete="one-time-code", which lets iOS and Android auto-fill the SMS code without any copy-paste. That last one is the single highest-impact improvement you can make to a mobile OTP form.

🎬 Live OTP Input

Type a digit to auto-advance. Backspace to go back. Paste "123456" to fill all.

🔐
Check your email
We sent a 6-digit code to
you@example.com
Didn't get the code?
← Back to login

Complete code

<form class="otp-card" method="POST" action="/api/verify-otp">
  <div class="otp-icon">🔐</div>
  <div class="otp-title">Check your email</div>
  <div class="otp-subtitle">
    We sent a 6-digit code to <strong>you@example.com</strong>
  </div>

  <div class="otp-inputs" id="otpInputs">
    <input type="text" inputmode="numeric" maxlength="1"
           class="otp-input" autocomplete="one-time-code" aria-label="Digit 1">
    <input type="text" inputmode="numeric" maxlength="1"
           class="otp-input" aria-label="Digit 2">
    <input type="text" inputmode="numeric" maxlength="1"
           class="otp-input" aria-label="Digit 3">
    <input type="text" inputmode="numeric" maxlength="1"
           class="otp-input" aria-label="Digit 4">
    <input type="text" inputmode="numeric" maxlength="1"
           class="otp-input" aria-label="Digit 5">
    <input type="text" inputmode="numeric" maxlength="1"
           class="otp-input" aria-label="Digit 6">
  </div>

  <button type="submit" class="otp-submit" id="otpSubmit" disabled>
    Verify code
  </button>

  <div class="otp-resend">
    Didn't get the code?
    <button type="button" id="otpResend" disabled>
      Resend in <span id="otpTimer">30</span>s
    </button>
  </div>
</form>

<style>
.otp-card {
  width: 100%; max-width: 400px;
  background: #1e293b;
  border: 1px solid #334155;
  border-radius: 1rem;
  padding: 2rem; text-align: center;
  box-shadow: 0 20px 50px rgba(0,0,0,0.3);
}

.otp-icon {
  width: 56px; height: 56px;
  background: linear-gradient(135deg, #3b82f6, #8b5cf6);
  border-radius: 14px;
  display: inline-flex; align-items: center; justify-content: center;
  font-size: 28px; margin-bottom: 1rem;
}
.otp-title { color: #fff; font-weight: 800; font-size: 1.35rem; margin-bottom: 0.5rem; }
.otp-subtitle { color: #94a3b8; font-size: 0.85rem; line-height: 1.5; margin-bottom: 1.75rem; }
.otp-subtitle strong { color: #cbd5e1; }

.otp-inputs {
  display: flex; justify-content: center;
  gap: 0.5rem; margin-bottom: 1.5rem;
}
.otp-input {
  width: 48px; height: 56px;
  background: #0f172a;
  border: 2px solid #334155;
  border-radius: 0.6rem;
  color: #fff; font-size: 1.5rem; font-weight: 700;
  text-align: center; font-family: inherit;
  transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
}
.otp-input:focus {
  outline: none;
  border-color: #3b82f6;
  background: #0c1e37;
  box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
}
.otp-input.filled {
  border-color: #3b82f6;
  background: #0c1e37;
}

.otp-submit {
  width: 100%; background: #3b82f6; color: #fff;
  border: none; padding: 0.8rem;
  border-radius: 0.5rem; font-weight: 700;
  font-size: 0.9rem; cursor: pointer;
  font-family: inherit; transition: background 0.2s;
  margin-bottom: 1rem;
}
.otp-submit:hover { background: #2563eb; }
.otp-submit:disabled { background: #475569; cursor: not-allowed; }

.otp-resend { color: #94a3b8; font-size: 0.8rem; }
.otp-resend button {
  background: none; border: none;
  color: #60a5fa; font-weight: 600;
  cursor: pointer; font-family: inherit;
  font-size: 0.8rem; padding: 0; text-decoration: underline;
}
.otp-resend button:disabled {
  color: #475569; cursor: not-allowed; text-decoration: none;
}
</style>

<script>
const inputs = document.querySelectorAll('.otp-input');
const form = document.getElementById('otpForm');
const submit = document.getElementById('otpSubmit');

// Auto-advance + backspace
inputs.forEach((input, index) => {
  input.addEventListener('input', (e) => {
    const value = e.target.value.replace(/\D/g, '');
    e.target.value = value;

    if (value) {
      e.target.classList.add('filled');
      if (index < inputs.length - 1) {
        inputs[index + 1].focus();
      }
    } else {
      e.target.classList.remove('filled');
    }

    updateSubmitState();
  });

  input.addEventListener('keydown', (e) => {
    if (e.key === 'Backspace' && !e.target.value && index > 0) {
      inputs[index - 1].focus();
    }
    if (e.key === 'ArrowLeft' && index > 0) {
      inputs[index - 1].focus();
      e.preventDefault();
    }
    if (e.key === 'ArrowRight' && index < inputs.length - 1) {
      inputs[index + 1].focus();
      e.preventDefault();
    }
  });

  input.addEventListener('paste', (e) => {
    e.preventDefault();
    const pasted = (e.clipboardData || window.clipboardData).getData('text');
    const digits = pasted.replace(/\D/g, '').slice(0, inputs.length).split('');

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

    const nextEmpty = Math.min(digits.length, inputs.length - 1);
    inputs[nextEmpty].focus();
    updateSubmitState();
  });
});

function updateSubmitState() {
  const allFilled = Array.from(inputs).every(i => i.value.length === 1);
  submit.disabled = !allFilled;
}

// Resend timer
let countdown = 30;
const timerEl = document.getElementById('otpTimer');
const resendBtn = document.getElementById('otpResend');

const timerInterval = setInterval(() => {
  countdown--;
  timerEl.textContent = countdown;

  if (countdown <= 0) {
    clearInterval(timerInterval);
    resendBtn.textContent = 'Resend code';
    resendBtn.disabled = false;
  }
}, 1000);

resendBtn.addEventListener('click', () => {
  countdown = 30;
  timerEl.textContent = countdown;
  resendBtn.disabled = true;
  resendBtn.innerHTML = 'Resend in <span id="otpTimer">30</span>s';
  // Re-start the timer here
});

// Form submit
form.addEventListener('submit', (e) => {
  e.preventDefault();
  const code = Array.from(inputs).map(i => i.value).join('');
  console.log('Verifying code:', code);
  // Replace with: fetch('/api/verify-otp', { method: 'POST', body: ... })
});
</script>

How the OTP input works

1. Six inputs, not one

The form uses six <input maxlength="1"> elements instead of a single input. This matches the visual pattern users see on their phone (6 separate boxes) and gives you full control over navigation. Each box accepts exactly one character.

2. inputmode="numeric" is the key mobile attribute

This triggers the numeric keypad on mobile — no need to switch keyboards. Use type="text" with inputmode="numeric", not type="number". Number inputs have quirks: scroll wheel changes values, leading zeros disappear, and the up/down arrows look wrong on a single digit.

3. Auto-advance on input

The input event listener checks if a digit was entered. If yes, it focuses the next input automatically. This is the single behavior that makes OTP inputs feel smooth. Without it, users have to tap each box manually — very slow.

4. Backspace goes back

If the user presses backspace on an empty input, focus moves to the previous input. If backspace is pressed on a filled input, the digit clears but focus stays. This is the natural behavior — matching what users expect from native code inputs.

5. Paste distributes across inputs

When the user pastes a 6-digit code, it would normally go into just one input (and get cut to 1 character by maxlength). The paste listener intercepts it, extracts the digits, and distributes them across all 6 inputs. This is critical for users pasting from SMS or a password manager.

6. autocomplete="one-time-code" is the killer feature

On iOS and Android, adding this attribute lets the browser auto-fill the SMS code from a recent SMS message. The user doesn't type or paste anything — the code appears in the inputs automatically when they focus the first one. This is the single highest-impact feature for mobile OTP UX, and it's one attribute.

🔬 Edge cases

The problems that break OTP inputs in production.

Paste inserts into one field only

Without a paste handler, pasting "123456" fills the first input with "1" and truncates the rest. Fix: listen for the paste event and distribute the digits. This is the #1 missing feature in OTP implementations.

Using type="number"

Number inputs strip leading zeros and show spinner arrows. Fix: use type="text" with inputmode="numeric". You get the mobile number pad without the quirks.

No auto-submit

The user types 6 digits and then has to click "Verify" manually. Fix: after the last digit, trigger form.submit() automatically (with a small delay to let the animation finish). Google and GitHub both auto-submit.

No resend timer

Users can click "Resend" repeatedly, flooding their email and hitting rate limits. Fix: disable the button for 30–60 seconds after each resend, with a visible countdown. This is UX protection and server-side rate limit protection combined.

No error state

If the code is wrong, the form silently rejects or reloads. Fix: on failed verification, show a clear error, shake the inputs (visual feedback), and clear the fields so the user can retype.

Autofocus loops on page load

If every input has autofocus, browsers cycle focus between them on load. Fix: autofocus only on the first input, or don't use it at all (let the user click). The demo above doesn't autofocus, which is safer.

iOS SMS autofill doesn't trigger

SMS autofill requires autocomplete="one-time-code" on the first input only, and the field must be focused when the SMS arrives. Also, the form must be over HTTPS and the SMS must contain "code" or "verification" in the message body for iOS to detect it.

Screen readers skip inputs

Without aria-labels, screen readers announce "text input" for each box, giving no context. Fix: add aria-label="Digit 1" etc. to each input. Or use aria-labelledby with a shared heading.

♿ Accessibility

OTP inputs are notoriously bad for screen readers. Here's what makes them work.

Requirement Implementation Why
Each input labeledaria-label="Digit N"Screen readers know which box
Status messagesrole="status" aria-live="polite"Announces "Code sent" / "Code verified"
Keyboard navigationArrow keys handledTab not required to move between digits
Numeric input modeinputmode="numeric"Triggers numeric keypad on mobile

Test with a screen reader. VoiceOver (macOS/iOS) and NVDA (Windows) both handle split inputs poorly by default. Add the aria-labels above — then test. If the experience is still bad, consider a single-input version with letter-spacing instead.

⚡ Performance

Aspect Value Notes
CSS size~2KBNo frameworks
JS size~40 linesAuto-advance + paste + timer
Layout shiftNoneFixed dimensions on inputs
Mobile keypadNumericinputmode="numeric" triggers it

The performance that matters is UX performance. An OTP screen that takes 5 seconds to type the code loses users. One that takes 2 seconds (auto-advance, paste, autofill) succeeds. The JS on this page is 40 lines — nobody has ever complained about its payload.

⚠️ Common mistakes

❓ Frequently asked questions

How do I create an OTP input field?

Create one input per digit (usually 6). Each input should have maxlength=1 and inputmode=numeric. Add JavaScript to auto-focus the next input when a digit is entered, and auto-focus the previous when backspace is pressed.

How do I make OTP paste work?

Listen for the paste event on the first input. Read clipboardData, split into characters, and distribute each character into the corresponding input. Auto-focus the last filled input after pasting.

Should I use one input or six for OTP?

Six separate inputs feel more polished and match the mental model of a 6-digit code. But a single input with letter-spacing can work and is easier to implement. For accessibility, either works if labeled properly.

What inputmode should OTP fields use?

Use inputmode='numeric' to trigger the numeric keypad on mobile. Use type='text' (not type='number') — number inputs have browser quirks like scroll-to-change and hide leading zeros.

How do I add a resend timer?

Store the current time when the OTP is sent. Start a setInterval that updates a countdown every second. Disable the resend button until the countdown reaches zero. Then re-enable it and reset the timer.

How do I auto-submit when all digits are filled?

After every input event, check if all inputs have a value. If yes, call form.submit() or trigger your verification function. This saves users a click and matches the pattern used by Google and GitHub.

What is the autocomplete='one-time-code' attribute?

It's a standard autocomplete value that lets browsers and OS auto-fill a received SMS OTP into the field. Supported on iOS Safari and most Android browsers. Add it to the first input only.

Should OTP inputs be a single field or separated?

Both work. Separated inputs are more visual and match the phone screen pattern. Single input is easier to build and screen readers handle it better. If using separated, make sure keyboard navigation works across them.

🔗 Related snippets

🌐 External resources

← All HTML Snippets