Source Code

<div class="container py-5 d-flex justify-content-center">
  <div class="card bsotp-card">
    <div class="card-body p-4">

      <div id="bsotpStep1">
        <h5 class="fw-bold mb-1 text-center">Log in</h5>
        <p class="text-muted small mb-3 text-center">Enter your email or phone to receive a one-time code.</p>
        <label class="form-label small fw-semibold" for="bsotpContact">Email or phone</label>
        <input type="text" class="form-control mb-3" id="bsotpContact" placeholder="you@example.com">
        <div class="small text-danger mb-2 d-none" id="bsotpContactError">Enter a valid email or phone number.</div>
        <button class="btn btn-dark w-100 fw-bold" id="bsotpSendBtn">Send code</button>
      </div>

      <div id="bsotpStep2" class="d-none">
        <h5 class="fw-bold mb-1 text-center">Enter the code</h5>
        <p class="text-muted small mb-3 text-center">We sent a 6-digit code to <span id="bsotpTarget" class="fw-semibold"></span>.</p>
        <div class="d-flex gap-2 justify-content-center mb-3" id="bsotpDigits">
          <input type="text" inputmode="numeric" maxlength="1" class="form-control text-center bsotp-box">
          <input type="text" inputmode="numeric" maxlength="1" class="form-control text-center bsotp-box">
          <input type="text" inputmode="numeric" maxlength="1" class="form-control text-center bsotp-box">
          <input type="text" inputmode="numeric" maxlength="1" class="form-control text-center bsotp-box">
          <input type="text" inputmode="numeric" maxlength="1" class="form-control text-center bsotp-box">
          <input type="text" inputmode="numeric" maxlength="1" class="form-control text-center bsotp-box">
        </div>
        <p class="small text-center mb-3" id="bsotpStatus">&nbsp;</p>
        <button class="btn btn-dark w-100 fw-bold mb-2" id="bsotpVerifyBtn" disabled>Verify &amp; log in</button>
        <p class="small text-muted text-center mb-0">
          <a href="javascript:void(0)" id="bsotpBack">&larr; Change contact</a>
          &nbsp;&middot;&nbsp;
          <a href="javascript:void(0)" id="bsotpResend">Resend code</a>
          <span id="bsotpTimer" class="text-muted"></span>
        </p>
      </div>

    </div>
  </div>
</div>

Bootstrap OTP Login Form — Free HTML CSS JS Snippet

Bootstrap OTP Login Form · Forms · Plain HTML, CSS & JS · Live preview

What's included

Features

Two-step card flow: contact entry, then OTP entry, swapped via Bootstrap d-none classes
Email or phone validation with a dedicated isValidContact() check and inline error state
Animated CSS fade-in transition when step two is revealed
Dynamic confirmation text showing the exact contact value the code was "sent" to
Six-box OTP input with auto-advance, backspace-to-previous, and full-code paste support
Live 30-second resend countdown that disables the Resend link and updates every second
Countdown is cleared with clearInterval when navigating back, preventing stale timers
Change-contact link returns to step one without losing the card layout

About this UI Snippet

Bootstrap OTP Login Form — HTML, CSS & JavaScript

Screenshot of the Bootstrap OTP Login Form snippet rendered live

This snippet models login as two distinct states inside one .card, not two pages: #bsotpStep1 collects a contact value in a Bootstrap form-control, and #bsotpStep2 — initially hidden with d-none — holds the six-box OTP entry. Clicking Send code runs isValidContact(), a small function that checks the typed value against either an email regex or a loose phone-number regex, so both formats are accepted. On success, revealStep2() toggles the d-none classes on both steps in one function, writes the entered contact into #bsotpTarget so the confirmation text is genuinely dynamic ("We sent a 6-digit code to you@example.com"), resets the six boxes, and focuses the first one. Because #bsotpStep2 carries a bsotpFadeIn keyframe animation triggered on d-none removal, the transition between steps reads as a reveal rather than an abrupt layout jump.

The six OTP boxes reuse the auto-advance / backspace-to-previous / full-paste-distribution pattern — typing a digit moves focus forward, Backspace on an empty box moves focus back, and a paste event listener spreads a multi-digit clipboard value across all six boxes starting from index 0. What's specific to this snippet is the resend flow: startCountdown() sets a 30-second RESEND_SECONDS timer, disables the Resend link by adding the disabled/text-muted classes and setting pointerEvents: 'none' (a class alone doesn't stop clicks on an <a>, so the inline style is required), and updates #bsotpTimer every second via setInterval with a live "(29s)" style countdown. When it reaches zero, clearInterval fires and the link's disabled styling and pointer-events lock are both removed, re-enabling it.

A pitfall this handles explicitly: switching back to step 1 via the "Change contact" link must stop the running countdown with clearInterval(countdownId) — otherwise a stale timer from a previous OTP request would keep ticking in the background and could re-enable a resend link that no longer exists in the visible step, or worse, leak an interval that never gets cleared across repeated back-and-forth navigation. Keeping countdownId in module scope and clearing it on every fresh startCountdown() call (not just on navigating back) also prevents two overlapping intervals from both writing to #bsotpTimer if Resend is somehow triggered twice in a row.

The contact validation itself deliberately accepts two very different shapes of input through one function rather than forcing the user to pick "email" or "phone" up front: isValidContact() runs the trimmed value through an email regex first, and only if that fails, through a looser phone regex accepting digits, spaces, parentheses, plus signs, and hyphens of at least seven characters. Returning true from either check is enough, so a login page doesn't need a separate toggle or tab for "email" vs "phone" login — the same input field and the same Send code button handle both, which mirrors how most real-world OTP login screens are built today. Because checkComplete() is called from within the shared input listener attached to every box in the same boxes.forEach loop that also handles digit-stripping and auto-advance, the Verify button's disabled state is always recalculated immediately after any keystroke, paste, or programmatic reset, rather than relying on a separate validation pass triggered only on submit.

Build with AI

Build, Understand, Optimize, and Extend It With AI

Hand this snippet's HTML, CSS, and JS to an AI coding assistant like Claude and ask it to persist the countdown across a page refresh using localStorage, or to add a shake animation on the boxes when verification fails. It's also worth asking for a loading spinner on the Send code button while the "request" is in flight.

Prompt to recreate it

Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:

text
Build a Bootstrap 5.3 two-step OTP login form, using the real Bootstrap CDN framework (bootstrap.min.css and bootstrap.bundle.min.js), not custom CSS made to resemble Bootstrap.

Requirements:
- Step one: a card with a single text input for email or phone, validated with a regex check, showing an inline error and a red form-control outline for invalid input.
- On valid submission, animate a transition (a CSS fade/slide keyframe is fine) from step one to step two within the same card, without a page navigation.
- Step two: a six-box OTP input reusing the standard auto-advance-on-type, backspace-moves-to-previous-box, and full-code-paste-distribution behaviors.
- Step two must echo back the exact contact value the user entered in step one, e.g. "We sent a code to you@example.com".
- Include a "Resend code" link that becomes disabled for 30 seconds after being used (and after the initial send), with a live countdown next to it updating every second via setInterval, then re-enabling itself.
- Include a "Change contact" link that returns to step one and must correctly stop the running countdown interval so it does not keep ticking in the background.
- A Verify button stays disabled until all six digits are entered, and on click shows a distinct success or error state.

Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.

Step by step

How to Use

  1. 1
    Load the snippetA single-step card appears asking for an email or phone number, with a Send code button.
  2. 2
    Type an invalid value and click Send codeThe field outlines red and an inline error appears below it; nothing advances.
  3. 3
    Type a valid email and click Send codeThe card fades into step two, showing six empty digit boxes and your entered contact echoed back in the subtext.
  4. 4
    Type the code 135790Focus auto-advances between boxes as you type, and the Verify button enables once all six are filled.
  5. 5
    Watch the Resend linkIt appears grayed out with a live "(30s)" countdown next to it that ticks down every second until it re-enables itself.
  6. 6
    Click Verify & log inA green success message appears for the correct code, or a red error with all six boxes outlined invalid for any other code.

Real-world uses

Common Use Cases

Passwordless login and OTP-based authentication
A complete two-step login pattern, complementary to a traditional bootstrap-login-form-validation form for apps offering both options.
Multi-step account flows
The same reveal-and-countdown pattern fits a bootstrap-multistep-signup-modal-style signup wizard that needs phone verification mid-flow.
Mobile-first apps favoring OTP over passwords
Common in apps where phone-number login is the primary flow rather than a fallback.
Learning countdown timers and step transitions
A clear, self-contained example of setInterval-driven UI state combined with a two-step reveal animation.
This library's other verification pattern
Compare the six-box handling here with the always-visible variant in bootstrap-2fa-verification-form to see the two-step reveal difference.

Got questions?

Frequently Asked Questions

That snippet shows the six-digit boxes immediately as a single-step form. This one models the realistic two-step login: a contact field first, then an animated reveal of the OTP boxes only after a valid contact is submitted, plus a live resend countdown that the 2FA snippet does not include.

Yes. In React, replace the two d-none toggles with a step state variable and useEffect for the setInterval cleanup (clearInterval in the effect's return function); in Vue, use ref for the step and onUnmounted to clear the interval; in Angular, manage the interval in ngOnDestroy so it is not left running after the component is destroyed.

Yes — clicking "Change contact" calls clearInterval on the stored countdownId before returning to step one, and starting a new send always calls startCountdown() fresh, which itself clears any prior interval first, so no two timers ever run simultaneously.

The email check requires a basic local@domain.tld shape via regex, and the phone check accepts 7 or more characters made up of digits, spaces, parentheses, plus signs, and hyphens — loose enough for most international formats without pretending to be a full validation library.

Keep the two-step HTML structure and all JS untouched, then swap .card/.card-body and .form-control for Tailwind utilities like rounded-xl border p-6 and border rounded px-3 py-2, and replace the .bsotp-box sizing with Tailwind's w-11 h-13 text-center classes.

Yes, 135790 for this front-end demo — in production, Send code would call your backend to actually dispatch an OTP, and Verify would POST the entered digits to a real endpoint instead of comparing against a constant.