Bootstrap Login Form: 16 Examples, From Simple to 2FA

Bootstrap handles the layout, validation styling and responsive behavior of a login form, while password checks, sessions and CSRF protection live on the server. It runs on 74.8% of websites whose CSS framework is known (W3Techs, October 2026).

Below are 16 Bootstrap login form examples you can copy, followed by a guide to centering, validation, password toggles and server-side security.

By Bogdan Sandu ยท Updated October 29, 2026

Every example runs on Bootstrap 5.3.8 from the jsDelivr CDN. Add these tags to your page, then paste an example's HTML, CSS and JS.

<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.2/font/bootstrap-icons.min.css" rel="stylesheet" integrity="sha384-nWN3KqaaVcbEsCjLSV+2jMjh62Iny/+mN3srM8TZeM8N6aj7eDl5wwYggjAJ4/sJ" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js" integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI" crossorigin="anonymous"></script>

Full sign-in screens and layouts

Standalone login screens: a centered card, a split screen with a photo, a dark console, a brand re-themed through CSS variables and a lock screen for expired sessions.

Centered card with icon inputs

  • card
  • input-group has-validation
  • is-invalid
  • aria-invalid
  • autocomplete

Press Sign in with empty fields, then fill them in.

A narrow card holds email and password fields with input-group-text icons. The input groups carry has-validation so the feedback text keeps the rounded corners. The submit handler toggles is-invalid and aria-invalid on the required fields only, because was-validated on the form would also paint the optional Remember me checkbox green, and it moves focus to the first invalid field.

View code
<div class="blg-classic w-100">
  <div class="text-center mb-4">
    <svg class="blg-classic-mark" width="48" height="48" viewBox="0 0 48 48" aria-hidden="true"><rect width="48" height="48" rx="13" fill="#1d4ed8"/><path d="M15 33V15h9a6 6 0 0 1 0 12h-9m9 0 8 6" fill="none" stroke="#fff" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
    <h2 class="h4 mt-3 mb-1">Sign in to Relaypoint</h2>
    <p class="text-body-secondary mb-0">Track every parcel your team ships.</p>
  </div>
  <div class="card border-0 shadow-sm">
    <div class="card-body p-4">
      <form class="blg-classic-form" novalidate>
        <div class="mb-3">
          <label for="blg-classic-email" class="form-label">Email address</label>
          <div class="input-group has-validation">
            <span class="input-group-text"><i class="bi bi-envelope" aria-hidden="true"></i></span>
            <input type="email" class="form-control" id="blg-classic-email" name="email" autocomplete="username" aria-describedby="blg-classic-email-fb" required>
            <div id="blg-classic-email-fb" class="invalid-feedback">Enter the email you signed up with.</div>
          </div>
        </div>
        <div class="mb-3">
          <label for="blg-classic-pass" class="form-label">Password</label>
          <div class="input-group has-validation">
            <span class="input-group-text"><i class="bi bi-lock" aria-hidden="true"></i></span>
            <input type="password" class="form-control" id="blg-classic-pass" name="password" autocomplete="current-password" aria-describedby="blg-classic-pass-fb" required>
            <div id="blg-classic-pass-fb" class="invalid-feedback">Enter your password.</div>
          </div>
        </div>
        <div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-4">
          <div class="form-check">
            <input class="form-check-input" type="checkbox" id="blg-classic-remember" name="remember">
            <label class="form-check-label" for="blg-classic-remember">Remember me</label>
          </div>
          <a href="#" class="small">Forgot password?</a>
        </div>
        <button type="submit" class="btn btn-primary w-100 py-2 blg-classic-btn">Sign in</button>
      </form>
    </div>
  </div>
  <p class="text-center small text-body-secondary mt-3 mb-0">No account yet? <a href="#">Start a free trial</a></p>
</div>

Split screen with photo and testimonial

  • row g-0
  • d-none d-md-block
  • form-control-lg
  • --bs-btn-*
  • figure

A two-column row gives half the screen to a photo with a short testimonial and the other half to the form. The photo column is hidden below md with d-none d-md-block, so phones go straight to the fields. The forest palette comes from --bs-btn-* variables and a scoped focus color, not from new button classes.

View code
<div class="blg-split">
  <div class="row g-0">
    <div class="col-md-6 d-none d-md-block blg-split-media">
      <img src="mountain-hiker.webp" alt="Hiker with a large backpack standing on a rocky ledge above a hazy mountain valley" width="800" height="600">
      <figure class="blg-split-quote">
        <blockquote class="mb-2">Trailmate planned our whole week in the Dolomites, mountain huts included.</blockquote>
        <figcaption>Ines R., hiking guide</figcaption>
      </figure>
    </div>
    <div class="col-md-6 d-flex align-items-center">
      <div class="p-4 p-sm-5 w-100">
        <h2 class="h3 mb-1">Welcome back</h2>
        <p class="text-body-secondary mb-4">Sign in to Trailmate to open your saved routes.</p>
        <form class="blg-split-form" novalidate>
          <div class="mb-3">
            <label for="blg-split-email" class="form-label">Email</label>
            <input type="email" class="form-control form-control-lg" id="blg-split-email" name="email" autocomplete="username" required>
            <div class="invalid-feedback">Enter a valid email address.</div>
          </div>
          <div class="mb-4">
            <div class="d-flex justify-content-between align-items-baseline">
              <label for="blg-split-pass" class="form-label">Password</label>
              <a href="#" class="small">Forgot it?</a>
            </div>
            <input type="password" class="form-control form-control-lg" id="blg-split-pass" name="password" autocomplete="current-password" required>
            <div class="invalid-feedback">Enter your password.</div>
          </div>
          <button type="submit" class="btn btn-lg w-100 blg-split-btn">Sign in</button>
          <div class="blg-split-or my-3"><span>or</span></div>
          <button type="button" class="btn btn-outline-secondary btn-lg w-100"><i class="bi bi-building me-2" aria-hidden="true"></i>Continue with SSO</button>
        </form>
        <p class="small text-body-secondary mt-4 mb-0">New to Trailmate? <a href="#">Create a free account</a></p>
      </div>
    </div>
  </div>
</div>

Dark mode console with floating labels

  • data-bs-theme="dark"
  • form-floating
  • --bs-body-bg
  • --bs-border-color
  • badge

Press Sign in with an empty password.

The page runs in Bootstrap dark color mode, and the wrapper only redefines --bs-body-bg and --bs-border-color, which the card, inputs and floating label backgrounds all read. Each input sits before its label and carries a placeholder, as form-floating requires, and the username field uses autocomplete="username" because it accepts a handle or an email.

View code
<div class="blg-console w-100">
  <div class="card border shadow-lg">
    <div class="card-body p-4 p-sm-5">
      <div class="d-flex align-items-center justify-content-between gap-2 mb-4">
        <span class="d-inline-flex align-items-center gap-2 fw-semibold"><i class="bi bi-hdd-stack blg-console-logo" aria-hidden="true"></i>Hangar CI</span>
        <span class="badge rounded-pill text-bg-dark border blg-console-region">eu-west-2</span>
      </div>
      <h2 class="h4 mb-1">Sign in to the console</h2>
      <p class="text-body-secondary mb-4">Use your workspace account to manage pipelines.</p>
      <form class="blg-console-form" novalidate>
        <div class="form-floating mb-3">
          <input type="text" class="form-control" id="blg-console-user" name="username" placeholder="Username or email" autocomplete="username" autocapitalize="none" spellcheck="false" required>
          <label for="blg-console-user">Username or email</label>
          <div class="invalid-feedback">Enter your username or email.</div>
        </div>
        <div class="form-floating mb-4">
          <input type="password" class="form-control" id="blg-console-pass" name="password" placeholder="Password" autocomplete="current-password" required>
          <label for="blg-console-pass">Password</label>
          <div class="invalid-feedback">Enter your password.</div>
        </div>
        <button type="submit" class="btn w-100 py-2 blg-console-btn">Sign in</button>
      </form>
      <div class="d-flex flex-wrap justify-content-between gap-2 mt-4 small">
        <a href="#" class="link-light link-offset-2">Use a hardware key</a>
        <span class="text-body-secondary"><span class="blg-console-dot" aria-hidden="true"></span>All systems normal</span>
      </div>
    </div>
  </div>
</div>

Brand theme from CSS variables

  • --bs-border-radius
  • --bs-link-color-rgb
  • form-switch
  • rounded-pill
  • --bs-btn-*

A loyalty-card login for a fictional bakery, themed without touching Sass: the wrapper sets --bs-body-bg, --bs-border-color, --bs-border-radius and the link color variables, and the button gets its own --bs-btn-* values. The checked switch and the focus ring need two scoped rules because Bootstrap compiles those colors into the stylesheet.

View code
<div class="blg-bake w-100">
  <div class="card">
    <div class="card-body p-4 p-sm-5">
      <svg class="blg-bake-mark mb-3" width="44" height="44" viewBox="0 0 44 44" aria-hidden="true"><circle cx="22" cy="22" r="22" fill="#9a3412"/><path d="M12 27c0-6 4.5-11 10-11s10 5 10 11z" fill="#fde9d6"/><path d="M17 27v-5m5 5v-7m5 7v-5" stroke="#9a3412" stroke-width="2" stroke-linecap="round"/></svg>
      <h2 class="blg-bake-title mb-1">Crumb &amp; Kettle rewards</h2>
      <p class="mb-4 blg-bake-muted">Sign in to see your stamps and order ahead.</p>
      <form class="blg-bake-form" novalidate>
        <div class="mb-3">
          <label for="blg-bake-email" class="form-label">Email</label>
          <input type="email" class="form-control rounded-pill px-3" id="blg-bake-email" name="email" autocomplete="username" required>
          <div class="invalid-feedback">We need the email on your rewards card.</div>
        </div>
        <div class="mb-3">
          <label for="blg-bake-pass" class="form-label">Password</label>
          <input type="password" class="form-control rounded-pill px-3" id="blg-bake-pass" name="password" autocomplete="current-password" required>
          <div class="invalid-feedback">Enter your password.</div>
        </div>
        <div class="form-check form-switch mb-4">
          <input class="form-check-input" type="checkbox" role="switch" id="blg-bake-keep" name="remember" checked>
          <label class="form-check-label" for="blg-bake-keep">Keep me signed in on this device</label>
        </div>
        <button type="submit" class="btn rounded-pill w-100 py-2 blg-bake-btn">Sign in</button>
      </form>
      <p class="small text-center mt-4 mb-0 blg-bake-muted"><a href="#">Forgot your password?</a> &middot; <a href="#">Join rewards</a></p>
    </div>
  </div>
</div>

Session expired lock screen

  • card
  • hidden username field
  • object-fit
  • rounded-circle
  • was-validated

Press Unlock with an empty password.

A re-authentication screen asks only for the password, because the app already knows who is signing back in. A hidden input with autocomplete="username" and the known email lets password managers pick the right saved login. The photo is decorative, so it has an empty alt and sits under a dark tint that keeps the card readable.

View code
<div class="blg-lock">
  <img class="blg-lock-bg" src="city-skyline.webp" alt="" width="800" height="600">
  <div class="card border-0 shadow-lg blg-lock-card">
    <div class="card-body p-4 text-center">
      <div class="blg-lock-avatar rounded-circle mx-auto mb-3" aria-hidden="true">DW</div>
      <h2 class="h5 mb-1">Session expired</h2>
      <p class="small text-body-secondary mb-4">Ledgerline signed you out after 30 minutes of inactivity. Enter your password to continue as <strong class="text-body">dana.w@example.com</strong>.</p>
      <form class="blg-lock-form text-start" novalidate>
        <input type="email" name="username" autocomplete="username" value="dana.w@example.com" hidden>
        <label for="blg-lock-pass" class="form-label">Password</label>
        <input type="password" class="form-control mb-1" id="blg-lock-pass" name="password" autocomplete="current-password" required>
        <div class="invalid-feedback mb-2">Enter your password to unlock.</div>
        <button type="submit" class="btn btn-dark w-100 mt-3 blg-lock-btn"><i class="bi bi-unlock me-2" aria-hidden="true"></i>Unlock</button>
      </form>
      <a href="#" class="d-inline-block small mt-3">Not Dana? Sign in with another account</a>
    </div>
  </div>
</div>

Multi-step and passwordless flows

Login rarely stops at two fields. These demos split email and password into steps, send a magic link, ask for a one-time code, reset a password inline and switch between sign in and sign up with tabs.

Two-step login: email, then password

  • d-none
  • btn-link
  • aria-live
  • checkValidity()
  • disabled

Enter any email, press Continue, then try Use a different email.

The email comes first, so the next screen can route the user to a password, SSO or a passkey. Both steps live in one form, which keeps the username and password fields together for password managers, and the inactive password field is disabled so checkValidity() ignores it. A back button returns to the email step and a polite live region announces each step.

View code
<div class="blg-steps w-100">
  <div class="card border-0 shadow-sm">
    <div class="card-body p-4 p-sm-5">
      <p class="small fw-semibold mb-3 blg-steps-count">Step 1 of 2</p>
      <form class="blg-steps-form" novalidate>
        <div id="blg-steps-one">
          <h2 class="h4 mb-1">Sign in to Quillbase</h2>
          <p class="text-body-secondary mb-4">Enter your work email to continue.</p>
          <label for="blg-steps-email" class="form-label">Work email</label>
          <input type="email" class="form-control mb-1" id="blg-steps-email" name="email" autocomplete="username" required>
          <div class="invalid-feedback">Enter an email address like name@company.com.</div>
          <button type="submit" class="btn w-100 mt-3 blg-steps-btn">Continue</button>
        </div>
        <div id="blg-steps-two" class="d-none">
          <button type="button" class="btn btn-link px-0 mb-3 blg-steps-back"><i class="bi bi-arrow-left me-1" aria-hidden="true"></i>Use a different email</button>
          <h2 class="h4 mb-1">Enter your password</h2>
          <p class="text-body-secondary mb-4 text-break">Signing in as <strong class="text-body" id="blg-steps-who"></strong></p>
          <div class="d-flex justify-content-between align-items-baseline">
            <label for="blg-steps-pass" class="form-label">Password</label>
            <a href="#" class="small">Forgot password?</a>
          </div>
          <input type="password" class="form-control mb-1" id="blg-steps-pass" name="password" autocomplete="current-password" required disabled>
          <div class="invalid-feedback">Enter your password.</div>
          <button type="submit" class="btn w-100 mt-3 blg-steps-btn">Sign in</button>
        </div>
        <div class="alert alert-success small mt-3 mb-0 d-none blg-steps-done">Password accepted. Opening your workspace.</div>
        <p class="visually-hidden blg-steps-live" aria-live="polite"></p>
      </form>
    </div>
  </div>
</div>

Passwordless magic link

  • d-none
  • tabindex="-1"
  • setTimeout countdown
  • disabled
  • text-break

Enter any email and press Email me a link.

A passwordless login asks only for an email and sends a one-time link. After submit the form is swapped for a confirmation panel that receives focus, repeats the address and offers a resend button that stays disabled during a 30-second cooldown, a common guard against inbox flooding. The countdown uses chained setTimeout calls.

View code
<div class="blg-magic w-100">
  <div class="card border-0 shadow-sm">
    <div class="card-body p-4 p-sm-5">
      <form class="blg-magic-form" novalidate>
        <div class="blg-magic-icon mb-3" aria-hidden="true"><i class="bi bi-magic"></i></div>
        <h2 class="h4 mb-1">Sign in without a password</h2>
        <p class="text-body-secondary mb-4">We will email you a link that signs you in to Plotwise.</p>
        <label for="blg-magic-email" class="form-label">Email address</label>
        <input type="email" class="form-control mb-1" id="blg-magic-email" name="email" autocomplete="username" required>
        <div class="invalid-feedback">Enter the email address you use for Plotwise.</div>
        <button type="submit" class="btn w-100 mt-3 blg-magic-btn"><i class="bi bi-envelope me-2" aria-hidden="true"></i>Email me a link</button>
        <p class="small text-body-secondary mt-3 mb-0">Prefer a password? <a href="#">Sign in with password</a></p>
      </form>
      <div class="blg-magic-sent d-none text-center" tabindex="-1">
        <div class="blg-magic-icon mx-auto mb-3" aria-hidden="true"><i class="bi bi-envelope-check"></i></div>
        <h2 class="h4 mb-2">Check your inbox</h2>
        <p class="text-body-secondary mb-4 text-break">We sent a sign-in link to <strong class="text-body blg-magic-addr"></strong>. It works once and expires in 15 minutes.</p>
        <button type="button" class="btn btn-outline-secondary w-100 blg-magic-resend">Resend link</button>
        <button type="button" class="btn btn-link btn-sm mt-2 blg-magic-change">Use a different email</button>
      </div>
    </div>
  </div>
</div>

Two-factor code step

  • autocomplete="one-time-code"
  • inputmode="numeric"
  • pattern
  • is-invalid
  • spinner-border-sm

Type or paste any six digits and press Verify.

One input, not six boxes: autocomplete="one-time-code" lets phones offer the code from a text message, inputmode="numeric" opens the number pad and pasting works in one go. The pattern [0-9]{6} decides when the field gets is-invalid, a small script strips spaces and letters as the user types, and the button shows a spinner while the code is checked.

View code
<div class="blg-otp w-100">
  <div class="card border-0 shadow-sm">
    <div class="card-body p-4 p-sm-5">
      <div class="blg-otp-icon mb-3" aria-hidden="true"><i class="bi bi-shield-lock"></i></div>
      <h2 class="h4 mb-1">Two-step verification</h2>
      <p class="text-body-secondary mb-4">Enter the 6-digit code from your authenticator app or the text we sent to the phone ending in 42.</p>
      <form class="blg-otp-form" novalidate>
        <label for="blg-otp-code" class="form-label">Verification code</label>
        <input type="text" class="form-control form-control-lg mb-1 blg-otp-code" id="blg-otp-code" name="code" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}" aria-describedby="blg-otp-help" required>
        <div class="invalid-feedback">The code has 6 digits.</div>
        <div id="blg-otp-help" class="form-text">Codes refresh every 30 seconds.</div>
        <div class="form-check mt-3">
          <input class="form-check-input" type="checkbox" id="blg-otp-trust" name="trust">
          <label class="form-check-label" for="blg-otp-trust">Trust this browser for 30 days</label>
        </div>
        <button type="submit" class="btn btn-dark w-100 mt-4 blg-otp-submit"><span class="spinner-border spinner-border-sm me-2 d-none" aria-hidden="true"></span><span class="blg-otp-label">Verify</span></button>
        <div class="small mt-3 blg-otp-status" role="status"></div>
      </form>
      <div class="d-flex flex-wrap justify-content-between gap-2 small mt-2">
        <button type="button" class="btn btn-link btn-sm p-0 blg-otp-resend">Send a new code</button>
        <a href="#">Use a recovery code</a>
      </div>
    </div>
  </div>
</div>

Forgot password as an inline collapse

  • collapse
  • data-bs-parent
  • aria-expanded
  • shown.bs.collapse
  • alert-success

Press Forgot password?, then Send reset link.

The sign-in form and the reset form are two collapse panels with the same data-bs-parent, so opening one closes the other without a page change, and the reset form copies over the email already typed. The confirmation stays generic (if an account exists...) so the page never confirms which emails are registered.

View code
<div class="blg-forgot w-100">
  <div class="card border-0 shadow-sm">
    <div class="card-body p-4 p-sm-5" id="blg-forgot-stack">
      <div class="collapse show" id="blg-forgot-signin" data-bs-parent="#blg-forgot-stack">
        <h2 class="h4 mb-1">Sign in to Tallyroom</h2>
        <p class="text-body-secondary mb-4">Your bookkeeping, one login away.</p>
        <form class="blg-forgot-login" novalidate>
          <label for="blg-forgot-email" class="form-label">Email</label>
          <input type="email" class="form-control mb-3" id="blg-forgot-email" name="email" autocomplete="username" required>
          <label for="blg-forgot-pass" class="form-label">Password</label>
          <input type="password" class="form-control mb-3" id="blg-forgot-pass" name="password" autocomplete="current-password" required>
          <div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-4">
            <div class="form-check">
              <input class="form-check-input" type="checkbox" id="blg-forgot-remember" name="remember">
              <label class="form-check-label" for="blg-forgot-remember">Remember me</label>
            </div>
            <button type="button" class="btn btn-link btn-sm p-0 blg-forgot-open" data-bs-toggle="collapse" data-bs-target="#blg-forgot-reset" aria-expanded="false" aria-controls="blg-forgot-reset">Forgot password?</button>
          </div>
          <button type="submit" class="btn btn-primary w-100 blg-forgot-btn">Sign in</button>
        </form>
      </div>
      <div class="collapse" id="blg-forgot-reset" data-bs-parent="#blg-forgot-stack">
        <h2 class="h4 mb-1">Reset your password</h2>
        <p class="text-body-secondary mb-4">Enter your email and we will send you a link to choose a new password.</p>
        <form class="blg-forgot-form" novalidate>
          <label for="blg-forgot-reset-email" class="form-label">Email</label>
          <input type="email" class="form-control mb-1" id="blg-forgot-reset-email" name="email" autocomplete="username" required>
          <div class="invalid-feedback">Enter a valid email address.</div>
          <button type="submit" class="btn btn-primary w-100 mt-3 blg-forgot-btn">Send reset link</button>
          <div class="alert alert-success small mt-3 mb-0 d-none text-break blg-forgot-done" role="status"></div>
        </form>
        <button type="button" class="btn btn-link btn-sm px-0 mt-3" data-bs-toggle="collapse" data-bs-target="#blg-forgot-signin" aria-expanded="true" aria-controls="blg-forgot-signin"><i class="bi bi-arrow-left me-1" aria-hidden="true"></i>Back to sign in</button>
      </div>
    </div>
  </div>
</div>

Sign in and sign up tabs

  • nav-pills
  • data-bs-toggle="pill"
  • tab-pane fade
  • --bs-nav-pills-*
  • new-password

Switch to Create account.

A segmented control built from nav-pills and the tab plugin switches between two forms in one card. The pills are restyled only through --bs-nav-pills-link-active-bg and the nav link color variables. The sign-up pane uses autocomplete="new-password" with a visible length rule, so password managers offer to generate a password there and fill the saved one on the sign-in pane.

View code
<div class="blg-tabs w-100">
  <div class="card border-0 shadow-sm">
    <div class="card-body p-4">
      <ul class="nav nav-pills nav-fill mb-4 blg-tabs-nav" id="blg-tabs-nav" role="tablist">
        <li class="nav-item" role="presentation">
          <button class="nav-link active" id="blg-tabs-in-tab" data-bs-toggle="pill" data-bs-target="#blg-tabs-in" type="button" role="tab" aria-controls="blg-tabs-in" aria-selected="true">Sign in</button>
        </li>
        <li class="nav-item" role="presentation">
          <button class="nav-link" id="blg-tabs-up-tab" data-bs-toggle="pill" data-bs-target="#blg-tabs-up" type="button" role="tab" aria-controls="blg-tabs-up" aria-selected="false">Create account</button>
        </li>
      </ul>
      <div class="tab-content">
        <div class="tab-pane fade show active" id="blg-tabs-in" role="tabpanel" aria-labelledby="blg-tabs-in-tab" tabindex="0">
          <form class="blg-tabs-form" novalidate>
            <div class="mb-3">
              <label for="blg-tabs-in-email" class="form-label">Email</label>
              <input type="email" class="form-control" id="blg-tabs-in-email" name="email" autocomplete="username" required>
            </div>
            <div class="mb-4">
              <label for="blg-tabs-in-pass" class="form-label">Password</label>
              <input type="password" class="form-control" id="blg-tabs-in-pass" name="password" autocomplete="current-password" required>
            </div>
            <button type="submit" class="btn btn-dark w-100 blg-tabs-btn">Sign in to Brightdesk</button>
          </form>
        </div>
        <div class="tab-pane fade" id="blg-tabs-up" role="tabpanel" aria-labelledby="blg-tabs-up-tab" tabindex="0">
          <form class="blg-tabs-form" novalidate>
            <div class="mb-3">
              <label for="blg-tabs-up-name" class="form-label">Full name</label>
              <input type="text" class="form-control" id="blg-tabs-up-name" name="name" autocomplete="name" required>
            </div>
            <div class="mb-3">
              <label for="blg-tabs-up-email" class="form-label">Work email</label>
              <input type="email" class="form-control" id="blg-tabs-up-email" name="email" autocomplete="email" required>
            </div>
            <div class="mb-4">
              <label for="blg-tabs-up-pass" class="form-label">Password</label>
              <input type="password" class="form-control" id="blg-tabs-up-pass" name="new-password" autocomplete="new-password" minlength="12" aria-describedby="blg-tabs-up-help" required>
              <div id="blg-tabs-up-help" class="form-text">At least 12 characters. A passphrase works well.</div>
            </div>
            <button type="submit" class="btn btn-dark w-100 blg-tabs-btn">Create free account</button>
          </form>
        </div>
      </div>
    </div>
  </div>
</div>

Login in a modal, dropdown or offcanvas

When signing in is a secondary action, the form lives in an overlay. Each one uses the Bootstrap JS plugin through data attributes and moves focus to the first field once it opens.

Sign-in modal from the header

  • modal
  • modal-dialog-centered
  • --bs-modal-*
  • shown.bs.modal
  • aria-labelledby

Open the overlay with its button.

A header button opens a centered modal with data-bs-toggle and data-bs-target. The autofocus attribute does nothing inside a Bootstrap modal, so a shown.bs.modal listener focuses the email field. The rounded corners, width and padding come from the --bs-modal-* variables on a prefixed class.

View code
<div class="blg-modal-bar w-100">
  <div class="d-flex align-items-center justify-content-between gap-3 bg-white border rounded-3 px-3 py-2">
    <span class="fw-semibold"><i class="bi bi-journal-text me-2" aria-hidden="true"></i>Fieldnote</span>
    <button type="button" class="btn btn-dark btn-sm px-3 blg-modal-open" data-bs-toggle="modal" data-bs-target="#blg-modal-login"><i class="bi bi-person-circle me-2" aria-hidden="true"></i>Sign in</button>
  </div>
</div>
<div class="modal fade blg-modal" id="blg-modal-login" tabindex="-1" aria-labelledby="blg-modal-title" aria-hidden="true">
  <div class="modal-dialog modal-dialog-centered">
    <div class="modal-content">
      <div class="modal-header border-0 pb-0">
        <h2 class="modal-title h5" id="blg-modal-title">Welcome back to Fieldnote</h2>
        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
      </div>
      <div class="modal-body">
        <p class="text-body-secondary small mb-3">Sign in to sync your notebooks across devices.</p>
        <form class="blg-modal-form" novalidate>
          <div class="mb-3">
            <label for="blg-modal-email" class="form-label">Email</label>
            <input type="email" class="form-control" id="blg-modal-email" name="email" autocomplete="username" required>
            <div class="invalid-feedback">Enter a valid email address.</div>
          </div>
          <div class="mb-3">
            <label for="blg-modal-pass" class="form-label">Password</label>
            <input type="password" class="form-control" id="blg-modal-pass" name="password" autocomplete="current-password" required>
            <div class="invalid-feedback">Enter your password.</div>
          </div>
          <div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
            <div class="form-check">
              <input class="form-check-input" type="checkbox" id="blg-modal-remember" name="remember">
              <label class="form-check-label" for="blg-modal-remember">Remember me</label>
            </div>
            <a href="#" class="small">Forgot password?</a>
          </div>
          <button type="submit" class="btn btn-dark w-100 blg-modal-btn">Sign in</button>
        </form>
      </div>
      <div class="modal-footer border-0 justify-content-center pt-0">
        <p class="small text-body-secondary mb-0">New to Fieldnote? <a href="#">Create an account</a></p>
      </div>
    </div>
  </div>
</div>

Login dropdown in the navbar

  • navbar-expand-md
  • dropdown-menu-end
  • data-bs-auto-close="outside"
  • shown.bs.dropdown
  • ms-auto order-md-last

Open the overlay with its button.

The navbar keeps the Sign in dropdown outside the collapse, so it stays visible next to the toggler on phones, and a static .dropdown wrapper anchors the menu to the navbar edge instead of the button. The menu holds a whole form and uses data-bs-auto-close="outside", which lets clicks inside the fields keep it open while a click elsewhere or Escape closes it. A shown.bs.dropdown listener puts focus in the email field.

View code
<div class="blg-dropdown">
  <nav class="navbar navbar-expand-md bg-body border-bottom">
    <div class="container-fluid px-3 px-sm-4">
      <a class="navbar-brand fw-semibold" href="#"><i class="bi bi-water me-2 blg-dropdown-logo" aria-hidden="true"></i>Quayside</a>
      <div class="d-flex align-items-center gap-2 ms-auto order-md-last">
        <div class="dropdown">
          <button type="button" class="btn btn-outline-dark blg-dropdown-toggle" id="blg-dropdown-btn" data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-expanded="false">Sign in</button>
          <div class="dropdown-menu dropdown-menu-end p-4 shadow blg-dropdown-menu">
            <form class="blg-dropdown-form" novalidate>
              <div class="mb-3">
                <label for="blg-dropdown-email" class="form-label">Email</label>
                <input type="email" class="form-control" id="blg-dropdown-email" name="email" autocomplete="username" required>
              </div>
              <div class="mb-3">
                <label for="blg-dropdown-pass" class="form-label">Password</label>
                <input type="password" class="form-control" id="blg-dropdown-pass" name="password" autocomplete="current-password" required>
              </div>
              <div class="form-check mb-3">
                <input class="form-check-input" type="checkbox" id="blg-dropdown-remember" name="remember">
                <label class="form-check-label" for="blg-dropdown-remember">Remember me</label>
              </div>
              <button type="submit" class="btn btn-dark w-100">Sign in</button>
            </form>
            <div class="dropdown-divider my-3"></div>
            <a class="d-block small mb-1" href="#">New here? Create an account</a>
            <a class="d-block small" href="#">Forgot password?</a>
          </div>
        </div>
        <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#blg-dropdown-nav" aria-controls="blg-dropdown-nav" aria-expanded="false" aria-label="Toggle navigation"><span class="navbar-toggler-icon"></span></button>
      </div>
      <div class="collapse navbar-collapse" id="blg-dropdown-nav">
        <ul class="navbar-nav ms-md-4 me-auto">
          <li class="nav-item"><a class="nav-link active" aria-current="page" href="#">Tours</a></li>
          <li class="nav-item"><a class="nav-link" href="#">Private boats</a></li>
          <li class="nav-item"><a class="nav-link" href="#">Gift cards</a></li>
        </ul>
      </div>
    </div>
  </nav>
  <div class="px-3 px-sm-4 py-5 blg-dropdown-hero">
    <h2 class="h3 mb-2">Harbour tours that leave from your street</h2>
    <p class="text-body-secondary mb-0">Sign in to see your bookings, boarding passes and saved routes.</p>
  </div>
</div>

Account panel in an offcanvas

  • offcanvas-end
  • --bs-offcanvas-width
  • shown.bs.offcanvas
  • btn-outline-secondary
  • aria-labelledby

Open the overlay with its button.

Shops often slide the sign-in form in from the side so the cart stays in view. The offcanvas-end panel is opened with data-bs-toggle="offcanvas", its width comes from --bs-offcanvas-width and Bootstrap caps it at the screen width on phones. A guest checkout button sits under the form as the alternative path.

View code
<div class="blg-offcanvas-bar w-100">
  <div class="d-flex align-items-center justify-content-between gap-3 bg-white border rounded-3 px-3 py-2">
    <span class="fw-semibold">Loam Goods</span>
    <div class="d-flex align-items-center gap-2">
      <span class="small text-body-secondary d-none d-sm-inline">Cart (2)</span>
      <button type="button" class="btn btn-outline-dark btn-sm blg-offcanvas-open" data-bs-toggle="offcanvas" data-bs-target="#blg-offcanvas-panel" aria-controls="blg-offcanvas-panel" aria-label="Account"><i class="bi bi-person" aria-hidden="true"></i></button>
    </div>
  </div>
</div>
<div class="offcanvas offcanvas-end blg-offcanvas" tabindex="-1" id="blg-offcanvas-panel" aria-labelledby="blg-offcanvas-title">
  <div class="offcanvas-header border-bottom">
    <h2 class="offcanvas-title h5" id="blg-offcanvas-title">Your account</h2>
    <button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
  </div>
  <div class="offcanvas-body">
    <p class="text-body-secondary small">Sign in to check out faster with saved addresses.</p>
    <form class="blg-offcanvas-form" novalidate>
      <div class="mb-3">
        <label for="blg-offcanvas-email" class="form-label">Email</label>
        <input type="email" class="form-control" id="blg-offcanvas-email" name="email" autocomplete="username" required>
        <div class="invalid-feedback">Enter a valid email address.</div>
      </div>
      <div class="mb-2">
        <label for="blg-offcanvas-pass" class="form-label">Password</label>
        <input type="password" class="form-control" id="blg-offcanvas-pass" name="password" autocomplete="current-password" required>
        <div class="invalid-feedback">Enter your password.</div>
      </div>
      <a href="#" class="d-inline-block small mb-4">Forgot password?</a>
      <button type="submit" class="btn btn-dark w-100 blg-offcanvas-btn">Sign in</button>
    </form>
    <div class="blg-offcanvas-or my-3"><span>or</span></div>
    <button type="button" class="btn btn-outline-secondary w-100 blg-offcanvas-btn">Check out as guest</button>
    <p class="small text-body-secondary mt-4 mb-0">No account? <a href="#">Create one</a> and get free returns.</p>
  </div>
</div>

Password reveal, loading, errors and SSO

The small states that decide whether a login form feels finished: a password toggle with aria-pressed, a spinner while the request runs, one generic error for wrong credentials and neutral sign-in options.

Show and hide password with Caps Lock warning

  • input-group
  • aria-pressed
  • bi-eye-slash
  • getModifierState
  • ::-ms-reveal

Type a password, then press the eye button.

The reveal button sits in an input group, has type="button" so it never submits, keeps one fixed label and reports its state with aria-pressed. A keyup check with getModifierState('CapsLock') shows a warning under the field, and a scoped ::-ms-reveal rule hides the Edge eye so users do not see two.

View code
<div class="blg-reveal w-100">
  <div class="card border-0 shadow-sm">
    <div class="card-body p-4">
      <h2 class="h5 mb-3">Sign in to Vaultline</h2>
      <form class="blg-reveal-form" novalidate>
        <div class="mb-3">
          <label for="blg-reveal-email" class="form-label">Email</label>
          <input type="email" class="form-control" id="blg-reveal-email" name="email" autocomplete="username" required>
          <div class="invalid-feedback">Enter a valid email address.</div>
        </div>
        <div class="mb-4">
          <label for="blg-reveal-pass" class="form-label">Password</label>
          <div class="input-group has-validation">
            <input type="password" class="form-control blg-reveal-input" id="blg-reveal-pass" name="password" autocomplete="current-password" aria-describedby="blg-reveal-caps" required>
            <button type="button" class="btn btn-outline-secondary blg-reveal-toggle" aria-label="Show password" aria-pressed="false" aria-controls="blg-reveal-pass"><i class="bi bi-eye" aria-hidden="true"></i></button>
            <div class="invalid-feedback">Enter your password.</div>
          </div>
          <div id="blg-reveal-caps" class="form-text text-warning-emphasis d-none"><i class="bi bi-exclamation-triangle me-1" aria-hidden="true"></i>Caps Lock is on.</div>
        </div>
        <button type="submit" class="btn btn-primary w-100 blg-reveal-btn">Sign in</button>
      </form>
    </div>
  </div>
</div>

Loading state and wrong-credentials error

  • spinner-border-sm
  • alert-danger
  • is-invalid
  • aria-describedby
  • disabled

Enter any email and password: every attempt fails in this demo.

On submit the button is disabled and shows a small spinner, then a simulated server reply marks both fields is-invalid, hides the per-field hints and shows one alert that does not say which credential was wrong. The password is cleared and focused, editing either field removes the error state, and a short shake runs only when the user allows motion.

View code
<div class="blg-error w-100">
  <div class="card border-0 shadow-sm blg-error-card">
    <div class="card-body p-4">
      <h2 class="h5 mb-1">Sign in to Orbitask</h2>
      <p class="text-body-secondary small mb-4">Plan sprints with your team.</p>
      <div id="blg-error-alert" class="alert alert-danger d-flex gap-2 align-items-start small d-none" role="alert">
        <i class="bi bi-exclamation-circle-fill mt-1" aria-hidden="true"></i>
        <div id="blg-error-text"></div>
      </div>
      <form class="blg-error-form" novalidate>
        <div class="mb-3">
          <label for="blg-error-email" class="form-label">Email</label>
          <input type="email" class="form-control" id="blg-error-email" name="email" autocomplete="username" aria-describedby="blg-error-text" required>
          <div class="invalid-feedback">Enter a valid email address.</div>
        </div>
        <div class="mb-4">
          <label for="blg-error-pass" class="form-label">Password</label>
          <input type="password" class="form-control" id="blg-error-pass" name="password" autocomplete="current-password" aria-describedby="blg-error-text" required>
          <div class="invalid-feedback">Enter your password.</div>
        </div>
        <button type="submit" class="btn btn-primary w-100 blg-error-submit"><span class="spinner-border spinner-border-sm me-2 d-none" aria-hidden="true"></span><span class="blg-error-label">Sign in</span></button>
        <p class="visually-hidden blg-error-live" aria-live="polite"></p>
      </form>
    </div>
  </div>
</div>

Sign-in options with SSO, passkey and email code

  • d-grid gap-2
  • btn-outline-*
  • collapse
  • bi-building
  • spinner-border-sm

Press Continue with SSO and enter a work email.

Alternative sign-in methods are generic outline buttons with neutral icons, stacked with d-grid gap-2, instead of third-party brand marks. Continue with SSO opens a collapse that asks for the work email, then shows the redirect state a real app would hand to its identity provider. The password form stays one click away below the divider.

View code
<div class="blg-sso w-100">
  <div class="card border-0 shadow-sm">
    <div class="card-body p-4 p-sm-5">
      <h2 class="h4 mb-1">Sign in to Northpeak HR</h2>
      <p class="text-body-secondary mb-4">Choose how you want to sign in.</p>
      <div class="d-grid gap-2">
        <button type="button" class="btn btn-outline-dark blg-sso-btn" data-bs-toggle="collapse" data-bs-target="#blg-sso-panel" aria-expanded="false" aria-controls="blg-sso-panel"><i class="bi bi-building me-2" aria-hidden="true"></i>Continue with SSO</button>
        <div class="collapse" id="blg-sso-panel">
          <form class="blg-sso-form border rounded-3 p-3 my-1" novalidate>
            <label for="blg-sso-email" class="form-label small fw-semibold">Work email</label>
            <input type="email" class="form-control mb-1" id="blg-sso-email" name="email" autocomplete="username" required>
            <div class="invalid-feedback">Enter your company email address.</div>
            <button type="submit" class="btn btn-dark btn-sm w-100 mt-2 blg-sso-go"><span class="spinner-border spinner-border-sm me-2 d-none" aria-hidden="true"></span><span class="blg-sso-label">Find my company</span></button>
            <p class="small text-body-secondary text-break mt-2 mb-0 blg-sso-status" role="status"></p>
          </form>
        </div>
        <button type="button" class="btn btn-outline-dark blg-sso-btn"><i class="bi bi-key me-2" aria-hidden="true"></i>Sign in with a passkey</button>
        <button type="button" class="btn btn-outline-dark blg-sso-btn"><i class="bi bi-envelope me-2" aria-hidden="true"></i>Email me a sign-in code</button>
      </div>
      <div class="blg-sso-or my-4"><span>or</span></div>
      <a href="#" class="btn btn-link w-100 blg-sso-pass">Sign in with email and password</a>
    </div>
  </div>
</div>

What does the HTML for a basic Bootstrap login form look like?

Short. You can read the whole thing on one screen, and most of it is labels and inputs.

Getting a working form onto the page goes like this:

  1. Load Bootstrap’s stylesheet in the head and add the viewport meta tag.
  2. Wrap the page content in a .container.
  3. Pair every input with a label. The label gets form-label and a for attribute, and the input gets form-control and a matching id.
  4. Add the remember me checkbox, the submit button and the forgot password link.
<form action="/login" method="post">
  <div class="mb-3">
    <label for="loginEmail" class="form-label">Email address</label>
    <input type="email" class="form-control" id="loginEmail" name="email" required>
  </div>
  <div class="mb-3">
    <label for="loginPassword" class="form-label">Password</label>
    <input type="password" class="form-control" id="loginPassword" name="password" required>
  </div>
  <div class="mb-3 form-check">
    <input type="checkbox" class="form-check-input" id="rememberMe" name="remember">
    <label class="form-check-label" for="rememberMe">Remember me</label>
  </div>
  <button type="submit" class="btn btn-primary w-100">Sign in</button>
  <p class="mt-3 mb-0 text-center"><a href="/forgot-password">Forgot your password?</a></p>
</form>
  • form-label styles the label text and adds the space between label and field.
  • Full width, padding, a border and a focus ring all come from form-control.
  • The checkbox and its label stay on one line thanks to form-check.
  • btn btn-primary styles the submit button, and w-100 stretches it across the form.
  • Spacing between the field groups is just mb-3, a margin utility.

Bootstrap’s form layout documentation calls margin utilities the easiest way to group labels, controls and feedback. It also advises giving every button an explicit type, because a button inside a form defaults to submit.

Phones need the viewport meta tag in the head, or mobile browsers render the page at desktop width. The tag is <meta name="viewport" content="width=device-width, initial-scale=1">.

Fields beyond email and password (selects, textareas, file inputs) follow the same label and control pattern. Bootstrap’s wider set of form controls covers them.

How to center a Bootstrap login form and keep it responsive

Put the form in a column inside a row and add justify-content-center to the row. Then set the column width per breakpoint. Vertical centering needs a flex container with align-items-center and min-vh-100 around everything.

Horizontal centering with the grid

As of Bootstrap v5.3.8, the default .container caps its width at 540px, 720px, 960px, 1140px and 1320px from the sm, md, lg, xl and xxl breakpoints (Bootstrap containers documentation).

Inside that container, Bootstrap’s twelve-column grid system sizes the login column.

<div class="container">
  <div class="row justify-content-center">
    <div class="col-12 col-sm-8 col-md-6 col-lg-4">
      <!-- login form here -->
    </div>
  </div>
</div>

On phones col-12 keeps the form full width. It narrows through col-sm-8 and col-md-6 as the screen grows, then settles at a third of the container with col-lg-4 on desktops.

Breakpoint classes work mobile-first. A class such as col-md-6 applies from 768px upward, and smaller screens fall back to the nearest smaller class.

Vertical centering with flex utilities

Use min-vh-100, not vh-100.

The vh-100 class fixes the height at the full viewport, so a form taller than a short landscape screen overflows. min-vh-100 sets a minimum instead, and the wrapper grows when the content needs more room.

<main class="d-flex align-items-center min-vh-100">
  <div class="container">
    <div class="row justify-content-center">
      <div class="col-12 col-sm-8 col-md-6 col-lg-4">
        <!-- login form here -->
      </div>
    </div>
  </div>
</main>

The wrapper becomes a flex container through d-flex, and align-items-center centers the container vertically. min-vh-100 gives it at least the full viewport height.

Test the result in Chrome DevTools device mode at 575px, 576px, 768px and 992px. Bootstrap’s max-width queries use values such as 575.98px, so the layout flips exactly at those thresholds.

Bootstrap login form layouts and when each one fits

Where the form lives decides the layout. A standalone login page works with a centered card or a split-screen. A sign-in button on content pages calls for a modal. Floating labels suit a form with two fields, and stacked labels stay the default once fields carry helper text.

LayoutKey classesBest forLimitation
Stacked labelsform-label, form-controlForms with helper textUses more vertical space
Floating labelsform-floatingShort two-field formsPlaceholder required, input before label
Centered cardcard, card-body, shadowStandalone login pagesExtra nesting inside the grid
Split-screenrow, col-lg-6, d-none d-lg-blockBranded app entry pagesImage column disappears below lg
Modalmodal, data-bs-toggleSecondary sign-in pointNeeds the JS bundle, no autofocus

Floating labels

Bootstrap’s floating labels documentation has some picky rules for the markup, and the element order is the one that trips people up first.

  • The input comes first and the label second, because the CSS relies on a sibling selector.
  • Every input needs a non-empty placeholder. The CSS-only effect reads :placeholder-shown to detect an empty field, and the placeholder text itself stays hidden.
  • In input groups, validation feedback goes outside .form-floating but inside the .input-group, and it has to be shown with JavaScript.
<div class="form-floating mb-3">
  <input type="email" class="form-control" id="floatingEmail" placeholder="name@example.com">
  <label for="floatingEmail">Email address</label>
</div>

For a login form with two fields, I’d take floating labels over stacked ones. Once helper text sits under the inputs, standard labels read better.

Modal login form

A modal suits sites where signing in is a secondary action, such as a button in the navbar. Bootstrap’s modal component handles the backdrop and closes on the Escape key by default.

  • Modals are built with HTML, CSS and JavaScript, so the Bootstrap JS bundle has to be loaded.
  • The trigger button carries data-bs-toggle="modal" and data-bs-target="#loginModal".
  • The autofocus attribute has no effect inside a modal, so focus the email field when the modal opens.
  • Only one modal can be open at a time. A sign-in modal can hand over to a password reset modal through the same toggle attributes.
const loginModal = document.getElementById('loginModal')
const loginEmail = document.getElementById('loginEmail')

loginModal.addEventListener('shown.bs.modal', () => {
  loginEmail.focus()
})

Split-screen and card layouts

A centered card puts the form inside Bootstrap’s card component, using card, card-body and a shadow utility, on a flex-centered page.

Split-screen layouts give the form half of a two-column row and a branded image the rest. Hide the image below lg with d-none d-lg-block, otherwise phones scroll past a photo before they reach the fields.

Bootstrap’s examples collection includes a Sign-in page, described as a custom form layout for a simple sign in form.

How does validation work in a Bootstrap login form?

Bootstrap leans on HTML5 attributes like required and type="email". Add novalidate to the form, then let a short script put the was-validated class on it at submit. That class switches on the green and red field styles and reveals the matching feedback messages.

Browser defaults

Browser defaults need no script. Remove novalidate and the browser shows its own popup on submit.

Wording and styling differ by browser and operating system, and CSS cannot restyle those popups. The text can be changed in JavaScript with setCustomValidity. All modern browsers implement the constraint validation API behind it (Bootstrap validation documentation, v5.3.8).

Custom styles with was-validated

Add novalidate to the form so the browser drops its popups, then let a short JavaScript handler call checkValidity on submit.

<form class="needs-validation" novalidate>
<div class="mb-3">
<label for="loginEmail" class="form-label">Email address</label>
<input type="email" class="form-control" id="loginEmail" required>
<div class="invalid-feedback">Enter a valid email address.</div>
</div>
<div class="mb-3">
<label for="loginPassword" class="form-label">Password</label>
<input type="password" class="form-control" id="loginPassword" required>
<div class="invalid-feedback">Enter your password.</div>
</div>
<button type="submit" class="btn btn-primary w-100">Sign in</button>
</form>

<script>
document.querySelectorAll('.needs-validation').forEach(form => {
  form.addEventListener('submit', event => {
    if (!form.checkValidity()) {
      event.preventDefault()
      event.stopPropagation()
    }
    form.classList.add('was-validated')
  })
})
</script>

Bootstrap scopes the :valid and :invalid styles to the was-validated parent class. Without it, a required field with no value looks invalid on page load.

After a dynamic submit with Ajax, remove was-validated from the form to reset its appearance.

Server-side classes

When the server decides a login failed, it marks fields with is-invalid and is-valid instead. These classes need no was-validated parent, and invalid-feedback works with them.

<input type="password" class="form-control is-invalid" id="loginPassword"
  name="password" aria-describedby="loginFeedback" required>
<div id="loginFeedback" class="invalid-feedback">
  The email or password is incorrect.
  </div>

Mark both fields is-invalid and show one message, so the response does not reveal which credential was wrong. Bootstrap’s server-side example links each message to its field through aria-describedby.

Accessibility fixes the default validation styles need

Bootstrap’s validation documentation states that its client-side custom validation styles and tooltips are not exposed to assistive technologies. Red borders and feedback text reach sighted users only. A Bootstrap form needs an announcement, aria-invalid and correct autocomplete tokens layered on top of the classes.

The same page disagrees with itself on the remedy. The notice at the top recommends server-side validation or browser defaults, while the server-side section says Bootstrap recommends client-side validation (v5.3.8 documentation).

For a login form I’d keep the custom styles and patch the gaps yourself.

Announce errors to screen readers

Add an alert region to the form and fill it from the submit handler. The ARIA alert role makes screen readers read new text without moving focus.

<form class="needs-validation" novalidate>
  <div id="formStatus" class="visually-hidden" role="alert"></div>
  <div class="mb-3">
    <label for="loginEmail" class="form-label">Email address</label>
    <input type="email" class="form-control" id="loginEmail"
      aria-describedby="emailFeedback" required>
    <div id="emailFeedback" class="invalid-feedback">Enter a valid email address.</div>
  </div>
  <!-- the password field follows the same pattern -->
</form>
form.addEventListener('submit', event => {
  const status = document.getElementById('formStatus')
  status.textContent = ''

  if (!form.checkValidity()) {
    event.preventDefault()
    event.stopPropagation()
    form.querySelectorAll(':invalid').forEach(field => field.setAttribute('aria-invalid', 'true'))
    status.textContent = 'Check the email and password fields.'

    form.querySelector(':invalid').focus()
  }
  form.classList.add('was-validated')
})

Here aria-invalid marks the failing fields and the alert region reads out a one-line summary. Focus then jumps to the first invalid field.

Clear the region at the start of each submit. A repeated identical message is not announced again.

Autocomplete tokens and password managers

WCAG 2.2 success criterion 3.3.8 (Level AA) bars cognitive function tests in authentication unless an alternative or an assisting mechanism exists. Password manager autofill and copy and paste both count as mechanisms, according to the W3C understanding document.

<input type="email" class="form-control" id="loginEmail" name="email"
  autocomplete="username" required>

<input type="password" class="form-control" id="loginPassword" name="password"
  autocomplete="current-password" required>

The same W3C document adds that a login form meeting criterion 1.3.5 (Input Purpose), with accessible names on its controls, lets browsers and password managers fill the fields.

A script that cancels paste on the password field fails the criterion for anyone who relies on a password manager. Scripts that stop the browser from populating fields fail it too. And a one-time-code input made of six boxes, where pasting fills only the first, shows up in the W3C document as a problem case.

Labels, focus and contrast

Every input keeps a real label element. A placeholder disappears as soon as the user types, which is why Bootstrap’s floating labels still require the label in the markup.

Wider guidance on accessible forms covers focus order, grouping and error text.

Bootstrap 5 raised its color contrast ratio from 3:1 to 4.5:1 for the default theme (v5.0.0 migration notes). Override btn-primary with a brand color and that guarantee is gone, so recheck the button text.

How to add a show and hide password toggle

Place the password input and a button inside an input group, then switch the input’s type between password and text on click. Give the button type="button" and an accessible name, or it submits the form and screen readers announce an unnamed control.

<div class="mb-3">
  <label for="loginPassword" class="form-label">Password</label>
  <div class="input-group">
    <input type="password" class="form-control" id="loginPassword" name="password" required>
    <button type="button" class="btn btn-outline-secondary" id="togglePassword"
        aria-label="Show password">
      <i class="bi bi-eye" aria-hidden="true"></i>
    </button>
  </div>
</div>
const passwordField = document.getElementById('loginPassword')
const toggleButton = document.getElementById('togglePassword')

toggleButton.addEventListener('click', () => {
  const show = passwordField.type === 'password'
  passwordField.type = show ? 'text' : 'password'
  toggleButton.setAttribute('aria-label', show ? 'Hide password' : 'Show password')
  toggleButton.firstElementChild.className = show ? 'bi bi-eye-slash' : 'bi bi-eye'
})

The eye and eye-slash glyphs come from Bootstrap Icons, loaded through its own stylesheet.

If the field also shows validation errors, add has-validation to the input group and place invalid-feedback inside it. Bootstrap added the class in v4.6.0 and v5.0 to fix the missing border radius on input groups with feedback (v5 migration guide).

For the announcement, either change the label with the state or keep one fixed label and use aria-pressed. Do both and a screen reader says “Hide password, pressed”.

Edge is the odd one out here. Microsoft Edge adds a native reveal button to password inputs, so Edge users see two eye icons next to a custom toggle.

Microsoft’s Edge documentation gives the removal rule, and users or enterprises can disable the native button globally from Edge 87 onward.

input[type="password"]::-ms-reveal {
  display: none;
}

The same documentation advises weighing the native button before removing it, because it hides itself when focus leaves the field, when a script modifies the field and when a password manager autofills it. Keep the custom toggle when the design needs one icon style everywhere, and hide the native button to avoid the duplicate.

What changes between Bootstrap 4 and Bootstrap 5 login forms?

Bootstrap 4 login markup breaks in a handful of places. form-group and form-row are gone, custom-control became form-check, ml-* and mr-* turned into ms-* and me-*, and data attributes gained a bs prefix.

Bootstrap 4 classBootstrap 5 replacementWhere it shows in a login form
form-groupmb-3 on a wrapper divSpacing between email and password
custom-control custom-checkboxform-checkRemember me checkbox
ml-2, mr-2ms-2, me-2Spacing around links and icons
input-group-appendButton as a direct child of input-groupPassword toggle
data-toggle, data-targetdata-bs-toggle, data-bs-targetModal trigger button
  • form-row and form-inline give way to grid classes such as row g-3
  • btn-block is replaced by a d-grid wrapper
  • sr-only is now visually-hidden
  • labels now require the form-label class

Bootstrap announced in July 2022 that Bootstrap 4 reaches end of life on January 1, 2023, with 4.6.2 as one of the last releases. Start new login forms on Bootstrap 5 and convert older snippets with the table above.

Removed classes fail silently. A form-group wrapper left in Bootstrap 5 markup loads no styles, so the fields lose their spacing without any console error.

Bootstrap 5.0.0 also dropped jQuery. Modal scripts copied from older tutorials call jQuery methods that no longer exist.

// Bootstrap 4 with jQuery
  $('#loginModal').modal('show')

  // Bootstrap 5
  bootstrap.Modal.getOrCreateInstance('#loginModal').show()

Security limits of a Bootstrap login form

Bootstrap styles the form and nothing more. It does not check passwords, keep sessions, stop bots or encrypt traffic, so every protection comes from the server and the transport layer.

Start with the checks. was-validated and checkValidity run in the browser, where anyone can skip them with developer tools or a direct request, so the server repeats every check.

Serve the login page and every authenticated page over TLS only (OWASP Authentication Cheat Sheet), and submit with method="post", since OWASP’s CSRF guidance rules out GET for requests that change state.

For CSRF, render a hidden input whose value the server generates. OWASP says the token should exist before authentication too, to stop login CSRF.

Failures should look identical. OWASP asks for the same response whether the ID or password was wrong, the account does not exist, or the account is locked or disabled. Registration and password reset follow the same rule.

None of this lives in the HTML. The backend that receives the POST decides who gets a session.

Use the framework’s built-in CSRF protection before writing custom token code. OWASP recommends built-in implementations first, and notes that cross-site scripting defeats every CSRF defense.

Bot checks cost money past a point. Google Cloud’s documentation, which now files reCAPTCHA under Fraud Defense, lists assessments as free up to 10,000 per month, and the Premium tier charges a flat $8 from there up to 100,000. Even the free allowance requires a billing account on the Google Cloud project.

Puzzle challenges that go beyond recognizing objects count as cognitive function tests under WCAG 3.3.8, so they need an alternative path. Object-recognition CAPTCHAs are excepted at Level AA, but not at Level AAA (3.3.9). Score-based reCAPTCHA keys run without a visible puzzle.

WebAuthn, the W3C standard developed with the FIDO Alliance, is the technology behind passkeys. A passkey flow lets the form shrink to an email field and a sign-in button, and moves the work to credential storage and recovery on the server.

Read together, the sources point one way. Keep the form friendly to password managers, prefer score-based bot checks over puzzles, and put every decision on the server.

You can skip the password form altogether when users never type a password, like these cases:

  • apps with single sign-on only, where an identity provider’s hosted page collects credentials
  • passkey-first products

Bootstrap Login Form FAQ

What separates a login form from a registration form?

Registration creates an account. A login form only verifies one that already exists. Registration adds a confirm password field and uses autocomplete=”new-password”, where login uses current-password.

Can I use a free third-party template for a login page?

Yes. Check which Bootstrap version it targets and what the license says, and read any scripts you didn’t write before they go live.

A template supplies markup only, so authentication still needs your own server code.

How do I change the colors of the form?

Override Sass variables after importing Bootstrap’s functions file and before its variables file, because every Bootstrap variable carries the !default flag and keeps any value assigned earlier (Bootstrap Sass documentation).

Since v5.3.0, validation colors also respond to CSS variables such as –bs-form-invalid-color. Bootswatch publishes ready-made themes built on the same Sass variables.

How do I add social login buttons?

Stack outline buttons below the submit button, each linking to a server route that starts the provider’s OAuth flow. Bootstrap only styles the link, using d-grid gap-2 for full-width stacking and Bootstrap Icons for the brand glyphs.

Follow each provider’s branding rules for the button wording.

How do I put icons inside the email and password fields?

Place a span with the input-group-text class before the input, inside an input group, and put a Bootstrap Icons glyph such as bi-envelope or bi-lock in it.

Mark the icon aria-hidden. The label already names the field.

Does the form work without a PHP or Node backend?

No. The form only sends a POST request, so a server route has to read the fields, check the credentials and start the session.

Set action to that route and give every input a name attribute, since the server reads values by name. Express needs express.urlencoded to parse the body, while PHP exposes it in $_POST.

Should the submit button stay disabled until the fields are valid?

Usually no. A disabled button cannot take focus and gives no reason for the block, so keyboard and screen reader users get stuck without an explanation.

Keep it enabled and let was-validated show the errors on submit. If the design demands a disabled state, set the button’s disabled property from checkValidity inside an input listener.

What to Build on the Server First

A Bootstrap login form is ready for production once the server covers transport and request forgery first, and credentials last. The order matters because each layer protects the next.

  1. HTTPS with a POST route
  2. CSRF token on the form
  3. Credential check with one generic failure message

The token travels inside the form, so it needs a secured connection before it protects anything. A credential check on an unsecured, forgeable request protects little.

The generic failure message has a price. OWASP’s Authentication Cheat Sheet calls the confusion for legitimate users a UX matter and leaves the final call to how critical the application and its data are.

Verified against the Bootstrap v5.3.8 documentation on October 29, 2026: its validation page still says custom styles are not accessible while the team works on a solution. A release that fixes that retires the extra ARIA announcements.