Bootstrap Form: 17 Examples With Validation and Layouts

Class names are the first thing to check in any Bootstrap form snippet, because they change between major versions. The Bootstrap 6 alpha, released October 8, 2026, drops form-select and swaps the .was-validated class for a data-bs-validate attribute.

Bootstrap 5.3 is still the stable line, so the markup here sticks to it. Below are 17 Bootstrap form examples you can try and copy, followed by a guide to classes, layouts, validation and accessibility.

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>

Sign-in, sign-up and onboarding forms

Account forms are short, so they are the best place for floating labels, live validation and a step-by-step flow. Each one is plain Bootstrap 5.3 markup with a few lines of scoped CSS.

Split sign-in card with floating labels

  • form-floating
  • card
  • row g-0
  • d-grid
  • autocomplete

A horizontal card puts a photo next to two form-floating fields. Each input comes before its label and carries a placeholder, which the floating label CSS needs, and the autocomplete values let password managers fill both fields. The custom CSS only sizes the card and crops the photo, which is hidden below the md breakpoint.

View code
<div class="card bfm-signin border-0 shadow-sm overflow-hidden">
  <div class="row g-0">
    <div class="col-md-5 d-none d-md-block">
      <img src="fjord.webp" class="bfm-signin-photo" alt="Deep blue fjord running between grey granite cliffs under a bright sky" width="800" height="600">
    </div>
    <div class="col-md-7">
      <div class="card-body p-4 p-sm-5">
        <h2 class="h4 mb-1">Sign in to Northbound</h2>
        <p class="text-body-secondary mb-4">Pick up your saved routes and cabin bookings.</p>
        <form class="bfm-signin-form">
          <div class="form-floating mb-3">
            <input type="email" class="form-control" id="bfm-signin-email" placeholder="name@example.com" autocomplete="username" required>
            <label for="bfm-signin-email">Email address</label>
          </div>
          <div class="form-floating mb-3">
            <input type="password" class="form-control" id="bfm-signin-pass" placeholder="Password" autocomplete="current-password" required>
            <label for="bfm-signin-pass">Password</label>
          </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="bfm-signin-keep">
              <label class="form-check-label" for="bfm-signin-keep">Keep me signed in</label>
            </div>
            <a href="#" class="small">Forgot password?</a>
          </div>
          <div class="d-grid">
            <button type="submit" class="btn btn-dark btn-lg">Sign in</button>
          </div>
        </form>
        <p class="small text-body-secondary text-center mt-4 mb-0">New to Northbound? <a href="#">Create an account</a></p>
      </div>
    </div>
  </div>
</div>

Registration with custom validation

  • novalidate
  • was-validated
  • invalid-feedback
  • setCustomValidity
  • aria-invalid

Press Create account with empty fields, then fill them in.

The form has novalidate, so the script runs checkValidity() on submit and adds .was-validated to show the green and red states. setCustomValidity() handles the password match, which required and minlength cannot express, and the script also sets aria-invalid and moves focus to the first invalid field because the colors alone are not announced.

View code
<div class="card bfm-reg border-0 shadow-sm">
  <div class="card-body p-4">
    <h2 class="h4 mb-1">Create your Ledgerly account</h2>
    <p class="text-body-secondary mb-4">All fields are required.</p>
    <div class="bfm-reg-done" role="status"></div>
    <form class="row g-3 bfm-reg-form" novalidate>
      <div class="col-sm-6">
        <label for="bfm-reg-first" class="form-label">First name</label>
        <input type="text" class="form-control" id="bfm-reg-first" autocomplete="given-name" required>
        <div class="invalid-feedback">Enter your first name.</div>
      </div>
      <div class="col-sm-6">
        <label for="bfm-reg-last" class="form-label">Last name</label>
        <input type="text" class="form-control" id="bfm-reg-last" autocomplete="family-name" required>
        <div class="invalid-feedback">Enter your last name.</div>
      </div>
      <div class="col-12">
        <label for="bfm-reg-email" class="form-label">Work email</label>
        <input type="email" class="form-control" id="bfm-reg-email" autocomplete="email" aria-describedby="bfm-reg-email-help" required>
        <div class="invalid-feedback">Enter an email address like name@company.com.</div>
        <div id="bfm-reg-email-help" class="form-text">We send a confirmation link to this address.</div>
      </div>
      <div class="col-sm-6">
        <label for="bfm-reg-pass" class="form-label">Password</label>
        <input type="password" class="form-control" id="bfm-reg-pass" autocomplete="new-password" minlength="10" aria-describedby="bfm-reg-pass-help" required>
        <div class="invalid-feedback">Use at least 10 characters.</div>
        <div id="bfm-reg-pass-help" class="form-text">At least 10 characters.</div>
      </div>
      <div class="col-sm-6">
        <label for="bfm-reg-confirm" class="form-label">Confirm password</label>
        <input type="password" class="form-control" id="bfm-reg-confirm" autocomplete="new-password" required>
        <div class="invalid-feedback">The two passwords must match.</div>
      </div>
      <div class="col-12">
        <div class="form-check">
          <input class="form-check-input" type="checkbox" id="bfm-reg-terms" required>
          <label class="form-check-label" for="bfm-reg-terms">I agree to the terms of service and privacy policy</label>
          <div class="invalid-feedback">You must accept the terms to continue.</div>
        </div>
      </div>
      <div class="col-12 d-grid">
        <button type="submit" class="btn btn-primary btn-lg bfm-reg-submit">Create account</button>
      </div>
    </form>
  </div>
</div>

Newsletter sign-up on a dark section

  • data-bs-theme="dark"
  • row g-2
  • col-sm-auto
  • visually-hidden
  • --bs-btn-*

Enter an email address and press Subscribe.

The dark stage sets data-bs-theme="dark", so the input, form text and validation colors switch to their dark-mode values without extra CSS. A row with col-sm and col-sm-auto keeps the field and button on one line from the sm breakpoint and stacks them on phones, and the label stays in the markup with .visually-hidden. The amber button is themed only through --bs-btn-* variables.

View code
<div class="bfm-news">
  <h2 class="h3 fw-bold mb-2">The Friday Build</h2>
  <p class="text-body-secondary mb-4">One short email a week: a component teardown, a CSS trick and a tool worth trying. No sponsors.</p>
  <form class="bfm-news-form" novalidate>
    <div class="row g-2">
      <div class="col-sm">
        <label for="bfm-news-email" class="visually-hidden">Email address</label>
        <input type="email" class="form-control form-control-lg" id="bfm-news-email" placeholder="you@example.com" autocomplete="email" aria-describedby="bfm-news-note" required>
        <div class="invalid-feedback">Enter a valid email address.</div>
      </div>
      <div class="col-sm-auto d-grid">
        <button type="submit" class="btn btn-lg bfm-news-btn">Subscribe</button>
      </div>
    </div>
    <p id="bfm-news-note" class="form-text mt-2 mb-0">Unsubscribe from any issue in one click.</p>
  </form>
  <p class="bfm-news-ok mb-0" role="status"></p>
</div>

Multi-step setup with a progress bar

  • progress
  • fieldset
  • was-validated
  • input-group
  • form-select

Fill in step 1 and press Next.

Each step is a fieldset, and only the current one is visible. The Next button is a submit button, so Enter works too, and the script validates just the visible fieldset by adding .was-validated to it, because the validation selectors match any ancestor. The progress bar updates its width and aria-valuenow, and a polite live region announces the step.

View code
<div class="card bfm-steps border-0 shadow-sm">
  <div class="card-body p-4">
    <div class="d-flex flex-wrap justify-content-between align-items-baseline gap-2 mb-2">
      <h2 class="h5 mb-0">Set up your workspace</h2>
      <span class="small text-body-secondary bfm-steps-count" aria-live="polite">Step 1 of 3</span>
    </div>
    <div class="progress mb-4 bfm-steps-progress" role="progressbar" aria-label="Setup progress" aria-valuenow="33" aria-valuemin="0" aria-valuemax="100">
      <div class="progress-bar bfm-steps-bar" style="width: 33%"></div>
    </div>
    <form class="bfm-steps-form" novalidate>
      <fieldset class="bfm-step">
        <legend class="fs-6 fw-semibold mb-3">Workspace details</legend>
        <div class="mb-3">
          <label for="bfm-steps-name" class="form-label">Workspace name</label>
          <input type="text" class="form-control" id="bfm-steps-name" placeholder="Acorn Design Co." required>
          <div class="invalid-feedback">Give your workspace a name.</div>
        </div>
        <div class="mb-1">
          <label for="bfm-steps-url" class="form-label">Workspace address</label>
          <div class="input-group has-validation">
            <span class="input-group-text" id="bfm-steps-url-prefix">pinboard.app/</span>
            <input type="text" class="form-control" id="bfm-steps-url" aria-describedby="bfm-steps-url-prefix bfm-steps-url-help" pattern="[a-z0-9\-]{3,30}" placeholder="acorn-design" required>
            <div class="invalid-feedback">Use 3 to 30 lowercase letters, numbers or hyphens.</div>
          </div>
          <div id="bfm-steps-url-help" class="form-text">Lowercase letters, numbers and hyphens.</div>
        </div>
      </fieldset>
      <fieldset class="bfm-step d-none">
        <legend class="fs-6 fw-semibold mb-3">Your team</legend>
        <div class="mb-3">
          <label for="bfm-steps-size" class="form-label">Team size</label>
          <select class="form-select" id="bfm-steps-size" required>
            <option value="" selected disabled>Choose a size</option>
            <option>Just me</option>
            <option>2 to 10 people</option>
            <option>11 to 50 people</option>
            <option>More than 50</option>
          </select>
          <div class="invalid-feedback">Pick a team size.</div>
        </div>
        <div class="mb-1">
          <label for="bfm-steps-role" class="form-label">Your role</label>
          <select class="form-select" id="bfm-steps-role" required>
            <option value="" selected disabled>Choose a role</option>
            <option>Designer</option>
            <option>Developer</option>
            <option>Project manager</option>
            <option>Founder</option>
          </select>
          <div class="invalid-feedback">Pick the role closest to yours.</div>
        </div>
      </fieldset>
      <fieldset class="bfm-step d-none">
        <legend class="fs-6 fw-semibold mb-3">Choose a plan</legend>
        <div class="form-check mb-2">
          <input class="form-check-input" type="radio" name="bfm-steps-plan" id="bfm-steps-free" required>
          <label class="form-check-label" for="bfm-steps-free">Starter, free for up to 3 boards</label>
        </div>
        <div class="form-check mb-1">
          <input class="form-check-input" type="radio" name="bfm-steps-plan" id="bfm-steps-team" required>
          <label class="form-check-label" for="bfm-steps-team">Team, $8 per member each month</label>
          <div class="invalid-feedback">Choose one of the plans.</div>
        </div>
      </fieldset>
      <div class="d-flex justify-content-between gap-2 mt-4">
        <button type="button" class="btn btn-outline-secondary bfm-steps-back" disabled>Back</button>
        <button type="submit" class="btn btn-primary px-4 bfm-steps-next">Next</button>
      </div>
    </form>
    <div class="bfm-steps-done" role="status"></div>
  </div>
</div>

Form layouts built on the grid

Contact, settings, filter and checkout forms that use rows, columns, gutters and col-form-label instead of v4 form-group and form-inline classes.

Studio contact form with a character counter

  • row g-3
  • col-md-6
  • form-select
  • textarea
  • form-text

Type a message to see the counter, then press Send.

Name and email share a row through col-md-6, the service select and the message take their own lines, and g-3 handles every gap, so there is no .mb-3 on the fields. The counter is a .form-text element linked through aria-describedby and updated from the textarea maxlength. Submitting runs the same checkValidity() pattern as the docs starter script.

View code
<div class="card bfm-contact border-0 shadow-sm">
  <div class="card-body p-4 p-md-5">
    <h2 class="h4 mb-1">Start a project with Halden Studio</h2>
    <p class="text-body-secondary mb-4">Tell us what you are building. We reply within two working days.</p>
    <div class="bfm-contact-done" role="status"></div>
    <form class="row g-3 bfm-contact-form" novalidate>
      <div class="col-md-6">
        <label for="bfm-contact-name" class="form-label">Your name</label>
        <input type="text" class="form-control" id="bfm-contact-name" autocomplete="name" required>
        <div class="invalid-feedback">Tell us who to reply to.</div>
      </div>
      <div class="col-md-6">
        <label for="bfm-contact-email" class="form-label">Email</label>
        <input type="email" class="form-control" id="bfm-contact-email" autocomplete="email" required>
        <div class="invalid-feedback">Enter a valid email address.</div>
      </div>
      <div class="col-md-6">
        <label for="bfm-contact-service" class="form-label">What do you need?</label>
        <select class="form-select" id="bfm-contact-service" required>
          <option value="" selected disabled>Choose a service</option>
          <option>Brand identity</option>
          <option>Website design</option>
          <option>Design system</option>
          <option>Something else</option>
        </select>
        <div class="invalid-feedback">Pick the closest match.</div>
      </div>
      <div class="col-md-6">
        <label for="bfm-contact-budget" class="form-label">Budget</label>
        <select class="form-select" id="bfm-contact-budget">
          <option selected>Not sure yet</option>
          <option>Under $10k</option>
          <option>$10k to $30k</option>
          <option>Over $30k</option>
        </select>
      </div>
      <div class="col-12">
        <label for="bfm-contact-msg" class="form-label">Project details</label>
        <textarea class="form-control" id="bfm-contact-msg" rows="4" maxlength="400" aria-describedby="bfm-contact-count" required></textarea>
        <div class="invalid-feedback">Add a few lines about the project.</div>
        <div id="bfm-contact-count" class="form-text">0 of 400 characters</div>
      </div>
      <div class="col-12">
        <div class="form-check">
          <input class="form-check-input" type="checkbox" id="bfm-contact-copy">
          <label class="form-check-label" for="bfm-contact-copy">Email me a copy of this message</label>
        </div>
      </div>
      <div class="col-12">
        <button type="submit" class="btn btn-dark px-4 bfm-contact-send">Send message</button>
      </div>
    </form>
  </div>
</div>

Horizontal store settings form

  • row
  • col-sm-3
  • col-form-label
  • form-control-plaintext
  • fieldset legend

Every field is a .row with a col-sm-3 label that carries .col-form-label, so the label text lines up with the middle of the control. The read-only store ID uses .form-control-plaintext, and the radio group is a fieldset whose legend gets .col-form-label and pt-0, as in the Bootstrap docs. Below the sm breakpoint the labels move above their fields on their own.

View code
<div class="card bfm-horiz border-0 shadow-sm">
  <div class="card-header bg-body py-3 px-4">
    <h2 class="h5 mb-0">Store settings</h2>
  </div>
  <div class="card-body p-4">
    <form class="bfm-horiz-form">
      <div class="row mb-3">
        <label for="bfm-horiz-id" class="col-sm-3 col-form-label">Store ID</label>
        <div class="col-sm-9">
          <input type="text" readonly class="form-control-plaintext font-monospace" id="bfm-horiz-id" value="CL-20418">
        </div>
      </div>
      <div class="row mb-3">
        <label for="bfm-horiz-name" class="col-sm-3 col-form-label">Store name</label>
        <div class="col-sm-9">
          <input type="text" class="form-control" id="bfm-horiz-name" value="Copperleaf Goods">
        </div>
      </div>
      <div class="row mb-3">
        <label for="bfm-horiz-email" class="col-sm-3 col-form-label">Support email</label>
        <div class="col-sm-9">
          <input type="email" class="form-control" id="bfm-horiz-email" value="help@copperleaf.example" aria-describedby="bfm-horiz-email-help">
          <div id="bfm-horiz-email-help" class="form-text">Shown on receipts and the contact page.</div>
        </div>
      </div>
      <div class="row mb-3">
        <label for="bfm-horiz-currency" class="col-sm-3 col-form-label">Currency</label>
        <div class="col-sm-9">
          <select class="form-select" id="bfm-horiz-currency">
            <option>EUR, euro</option>
            <option selected>USD, US dollar</option>
            <option>GBP, pound sterling</option>
          </select>
        </div>
      </div>
      <fieldset class="row mb-4">
        <legend class="col-form-label col-sm-3 pt-0">Prices shown</legend>
        <div class="col-sm-9">
          <div class="form-check">
            <input class="form-check-input" type="radio" name="bfm-horiz-tax" id="bfm-horiz-tax-in" checked>
            <label class="form-check-label" for="bfm-horiz-tax-in">Including tax</label>
          </div>
          <div class="form-check">
            <input class="form-check-input" type="radio" name="bfm-horiz-tax" id="bfm-horiz-tax-ex">
            <label class="form-check-label" for="bfm-horiz-tax-ex">Excluding tax</label>
          </div>
        </div>
      </fieldset>
      <div class="row">
        <div class="col-sm-9 offset-sm-3 d-flex flex-wrap gap-2">
          <button type="submit" class="btn btn-primary px-4">Save settings</button>
          <button type="reset" class="btn btn-link text-body-secondary">Discard</button>
        </div>
      </div>
    </form>
  </div>
</div>

Inline job filter bar

  • row-cols-md-auto
  • g-2
  • input-group
  • visually-hidden
  • role="search"

Type a keyword and press Search.

This is the v5 replacement for .form-inline: the form is a row with row-cols-md-auto, every field sits in a .col-12, and the fields line up from md upward and stack below it. The docs example uses row-cols-lg-auto, swapped to md here so the bar is inline in a narrower frame. Labels stay in the markup with .visually-hidden, and the result count is a polite live region.

View code
<div class="bfm-filter">
  <form class="row row-cols-md-auto g-2 align-items-center bfm-filter-form" role="search" aria-label="Filter open roles">
    <div class="col-12 flex-md-grow-1">
      <label class="visually-hidden" for="bfm-filter-q">Keyword</label>
      <div class="input-group">
        <span class="input-group-text bg-body"><i class="bi bi-search" aria-hidden="true"></i></span>
        <input type="search" class="form-control border-start-0" id="bfm-filter-q" placeholder="Role, skill or company">
      </div>
    </div>
    <div class="col-12">
      <label class="visually-hidden" for="bfm-filter-place">Location</label>
      <select class="form-select" id="bfm-filter-place">
        <option selected>Anywhere</option>
        <option>Lisbon</option>
        <option>Berlin</option>
        <option>Toronto</option>
      </select>
    </div>
    <div class="col-12">
      <div class="form-check">
        <input class="form-check-input" type="checkbox" id="bfm-filter-remote" checked>
        <label class="form-check-label" for="bfm-filter-remote">Remote only</label>
      </div>
    </div>
    <div class="col-12 d-grid">
      <button type="submit" class="btn btn-dark px-4">Search</button>
    </div>
  </form>
  <p class="small text-body-secondary mt-3 mb-3 bfm-filter-status" aria-live="polite">Showing 128 open roles</p>
  <ul class="list-group bfm-filter-list">
    <li class="list-group-item d-flex flex-wrap justify-content-between align-items-center gap-2">
      <span><span class="fw-semibold d-block">Senior product designer</span><span class="small text-body-secondary">Fieldnote, Lisbon or remote</span></span>
      <span class="badge text-bg-light border">Full time</span>
    </li>
    <li class="list-group-item d-flex flex-wrap justify-content-between align-items-center gap-2">
      <span><span class="fw-semibold d-block">Front-end engineer, design systems</span><span class="small text-body-secondary">Orbitly, remote in Europe</span></span>
      <span class="badge text-bg-light border">Full time</span>
    </li>
    <li class="list-group-item d-flex flex-wrap justify-content-between align-items-center gap-2">
      <span><span class="fw-semibold d-block">Brand illustrator</span><span class="small text-body-secondary">Paper Kite, Toronto</span></span>
      <span class="badge text-bg-light border">Contract</span>
    </li>
  </ul>
</div>

Checkout address form with order summary

  • row g-3
  • col-sm-*
  • order-md-last
  • list-group
  • autocomplete

Two grids work together: an outer row places the summary beside the form from md upward (order-md-last moves it right, so phones see it first), and an inner row g-3 sizes the address fields with col-sm-6, col-sm-5, col-sm-4 and col-sm-3. Standard autocomplete tokens such as address-line1 and postal-code let browsers fill the whole address.

View code
<div class="bfm-checkout">
  <div class="row g-4">
    <div class="col-md-5 order-md-last">
      <div class="card border-0 shadow-sm">
        <div class="card-body">
          <h2 class="h6 d-flex justify-content-between align-items-center mb-3">Your order <span class="badge rounded-pill text-bg-dark">2</span></h2>
          <ul class="list-group list-group-flush mb-0">
            <li class="list-group-item d-flex justify-content-between px-0"><span>Linen apron, rust<br><small class="text-body-secondary">Qty 1</small></span><span>$38.00</span></li>
            <li class="list-group-item d-flex justify-content-between px-0"><span>Stoneware mug set<br><small class="text-body-secondary">Qty 1</small></span><span>$46.00</span></li>
            <li class="list-group-item d-flex justify-content-between px-0"><span>Shipping</span><span>$6.50</span></li>
            <li class="list-group-item d-flex justify-content-between px-0 fw-semibold"><span>Total</span><span>$90.50</span></li>
          </ul>
        </div>
      </div>
    </div>
    <div class="col-md-7">
      <form class="card border-0 shadow-sm bfm-checkout-form">
        <div class="card-body p-4">
          <h2 class="h5 mb-3">Shipping address</h2>
          <div class="row g-3">
            <div class="col-sm-6">
              <label for="bfm-co-first" class="form-label">First name</label>
              <input type="text" class="form-control" id="bfm-co-first" autocomplete="shipping given-name">
            </div>
            <div class="col-sm-6">
              <label for="bfm-co-last" class="form-label">Last name</label>
              <input type="text" class="form-control" id="bfm-co-last" autocomplete="shipping family-name">
            </div>
            <div class="col-12">
              <label for="bfm-co-address" class="form-label">Address</label>
              <input type="text" class="form-control" id="bfm-co-address" placeholder="1234 Main St" autocomplete="shipping address-line1">
            </div>
            <div class="col-12">
              <label for="bfm-co-address2" class="form-label">Apartment, suite <span class="text-body-secondary">(optional)</span></label>
              <input type="text" class="form-control" id="bfm-co-address2" autocomplete="shipping address-line2">
            </div>
            <div class="col-sm-5">
              <label for="bfm-co-city" class="form-label">City</label>
              <input type="text" class="form-control" id="bfm-co-city" autocomplete="shipping address-level2">
            </div>
            <div class="col-sm-4">
              <label for="bfm-co-state" class="form-label">State</label>
              <select class="form-select" id="bfm-co-state" autocomplete="shipping address-level1">
                <option value="" selected>Choose</option>
                <option>California</option>
                <option>Oregon</option>
                <option>Washington</option>
              </select>
            </div>
            <div class="col-sm-3">
              <label for="bfm-co-zip" class="form-label">ZIP</label>
              <input type="text" class="form-control" id="bfm-co-zip" inputmode="numeric" autocomplete="shipping postal-code">
            </div>
            <div class="col-12">
              <div class="form-check">
                <input class="form-check-input" type="checkbox" id="bfm-co-same" checked>
                <label class="form-check-label" for="bfm-co-same">Billing address is the same</label>
              </div>
            </div>
            <div class="col-12 d-grid">
              <button type="submit" class="btn btn-primary btn-lg">Continue to shipping</button>
            </div>
          </div>
        </div>
      </form>
    </div>
  </div>
</div>

Input groups, switches, ranges and other controls

The less common controls: add-ons, switches with role="switch", range sliders with live output, file and color inputs, and btn-check toggles.

Tip link builder with input groups

  • input-group
  • input-group-text
  • has-validation
  • readonly
  • btn-outline-*

Enter a handle and amount, then press Build link.

Three input groups: an @ prefix with .has-validation so the rounded corners survive the invalid state, a dollar amount with $ and .00 add-ons, and a read-only field with a button attached directly, with no v4 append wrapper. The button checks the handle with checkValidity() and builds the link from the field values.

View code
<div class="card bfm-tip border-0 shadow-sm">
  <div class="card-body p-4">
    <h2 class="h5 mb-1">Your Kettle tip link</h2>
    <p class="text-body-secondary small mb-4">Share it in your bio. Fans can change the amount before they pay.</p>
    <form class="bfm-tip-form" novalidate>
      <div class="mb-3">
        <label for="bfm-tip-handle" class="form-label">Handle</label>
        <div class="input-group has-validation">
          <span class="input-group-text">@</span>
          <input type="text" class="form-control" id="bfm-tip-handle" pattern="[a-z0-9_]{3,20}" placeholder="your_handle" required>
          <div class="invalid-feedback">Use 3 to 20 lowercase letters, numbers or underscores.</div>
        </div>
      </div>
      <div class="mb-3">
        <label for="bfm-tip-amount" class="form-label">Suggested tip</label>
        <div class="input-group">
          <span class="input-group-text">$</span>
          <input type="number" class="form-control" id="bfm-tip-amount" min="1" max="500" step="1" value="5">
          <span class="input-group-text">.00</span>
        </div>
      </div>
      <label for="bfm-tip-link" class="form-label">Link</label>
      <div class="input-group">
        <input type="text" class="form-control font-monospace" id="bfm-tip-link" readonly value="Not built yet">
        <button class="btn btn-outline-dark bfm-tip-build" type="submit"><i class="bi bi-link-45deg me-1" aria-hidden="true"></i>Build link</button>
      </div>
    </form>
  </div>
</div>

Notification settings with switches

  • form-switch
  • role="switch"
  • form-check-inline
  • list-group-flush
  • data-bs-theme

Flip a switch to enable Save.

Each row pairs a label and description with a .form-switch checkbox that has role="switch", so screen readers announce it as on or off, and aria-describedby adds the description. The scoped CSS only enlarges the switches. On the dark stage the card, list and inputs follow data-bs-theme="dark", and the script enables Save when anything changes.

View code
<form class="card bfm-set">
  <div class="card-header py-3 px-4">
    <h2 class="h5 mb-0">Notifications</h2>
  </div>
  <ul class="list-group list-group-flush">
    <li class="list-group-item d-flex justify-content-between align-items-start gap-3 px-4 py-3">
      <div>
        <label class="fw-semibold" for="bfm-set-mentions">Mentions</label>
        <div class="small text-body-secondary" id="bfm-set-mentions-desc">When someone mentions you in a comment.</div>
      </div>
      <div class="form-check form-switch m-0">
        <input class="form-check-input" type="checkbox" role="switch" id="bfm-set-mentions" aria-describedby="bfm-set-mentions-desc" checked>
      </div>
    </li>
    <li class="list-group-item d-flex justify-content-between align-items-start gap-3 px-4 py-3">
      <div>
        <label class="fw-semibold" for="bfm-set-deploys">Deploy results</label>
        <div class="small text-body-secondary" id="bfm-set-deploys-desc">Failed and successful builds on the main branch.</div>
      </div>
      <div class="form-check form-switch m-0">
        <input class="form-check-input" type="checkbox" role="switch" id="bfm-set-deploys" aria-describedby="bfm-set-deploys-desc">
      </div>
    </li>
    <li class="list-group-item d-flex justify-content-between align-items-start gap-3 px-4 py-3">
      <div>
        <label class="fw-semibold" for="bfm-set-billing">Billing alerts</label>
        <div class="small text-body-secondary" id="bfm-set-billing-desc">Required while you are a workspace owner.</div>
      </div>
      <div class="form-check form-switch m-0">
        <input class="form-check-input" type="checkbox" role="switch" id="bfm-set-billing" aria-describedby="bfm-set-billing-desc" checked disabled>
      </div>
    </li>
    <li class="list-group-item px-4 py-3">
      <fieldset>
        <legend class="fs-6 fw-semibold mb-2">Email digest</legend>
        <div class="form-check form-check-inline">
          <input class="form-check-input" type="radio" name="bfm-set-digest" id="bfm-set-daily">
          <label class="form-check-label" for="bfm-set-daily">Daily</label>
        </div>
        <div class="form-check form-check-inline">
          <input class="form-check-input" type="radio" name="bfm-set-digest" id="bfm-set-weekly" checked>
          <label class="form-check-label" for="bfm-set-weekly">Weekly</label>
        </div>
        <div class="form-check form-check-inline">
          <input class="form-check-input" type="radio" name="bfm-set-digest" id="bfm-set-never">
          <label class="form-check-label" for="bfm-set-never">Never</label>
        </div>
      </fieldset>
    </li>
  </ul>
  <div class="card-footer d-flex flex-wrap justify-content-between align-items-center gap-2 py-3 px-4">
    <span class="small text-body-secondary bfm-set-state" aria-live="polite">All changes saved</span>
    <button type="submit" class="btn btn-primary bfm-set-save" disabled>Save changes</button>
  </div>
</form>

Export panel with range, color and file inputs

  • form-range
  • output
  • form-control-color
  • type="file"
  • form-control-sm

Drag the slider and pick a color.

The quality slider is a .form-range with min, max and step, and an output element shows its value as it moves. The color picker is .form-control plus .form-control-color on type="color", and the watermark upload is a plain .form-control on type="file", the v5 replacement for .custom-file. A small preview tile reads the chosen color.

View code
<div class="card bfm-exp border-0 shadow-sm">
  <div class="card-body p-4">
    <h2 class="h5 mb-4">Export artboard</h2>
    <form class="bfm-exp-form">
      <div class="mb-4">
        <div class="d-flex justify-content-between align-items-baseline">
          <label for="bfm-exp-quality" class="form-label mb-1">JPEG quality</label>
          <output for="bfm-exp-quality" class="fw-semibold font-monospace bfm-exp-out">80%</output>
        </div>
        <input type="range" class="form-range" id="bfm-exp-quality" min="10" max="100" step="5" value="80">
        <div class="d-flex justify-content-between small text-body-secondary"><span>Smaller file</span><span>Sharper image</span></div>
      </div>
      <div class="d-flex align-items-end gap-3 mb-4">
        <div>
          <label for="bfm-exp-color" class="form-label">Background</label>
          <input type="color" class="form-control form-control-color" id="bfm-exp-color" value="#1f6f5c" title="Choose a background color">
        </div>
        <div class="bfm-exp-preview flex-grow-1 rounded-3 d-flex align-items-center justify-content-center" aria-hidden="true">
          <span class="bfm-exp-chip">Artboard 1</span>
        </div>
      </div>
      <div class="mb-4">
        <label for="bfm-exp-mark" class="form-label">Watermark <span class="text-body-secondary">(PNG or SVG)</span></label>
        <input class="form-control form-control-sm" type="file" id="bfm-exp-mark" accept="image/png,image/svg+xml" aria-describedby="bfm-exp-mark-help">
        <div id="bfm-exp-mark-help" class="form-text">Placed in the bottom-right corner at 40% opacity.</div>
      </div>
      <div class="d-grid">
        <button type="submit" class="btn btn-success btn-lg">Export JPEG</button>
      </div>
    </form>
  </div>
</div>

Invoice payment form with formatted fields

  • input-group
  • inputmode
  • autocomplete="cc-*"
  • row g-3
  • Bootstrap Icons

Type digits into the card number field.

A layout demo only, with placeholder zeros: real card fields belong inside your payment provider's hosted fields. The card number sits in an input group with an icon add-on, inputmode="numeric" brings up the number keypad on phones, and the cc-* autocomplete tokens let browsers fill saved cards. The script groups digits in fours and formats the expiry as MM / YY while typing.

View code
<div class="card bfm-pay border-0 shadow-sm">
  <div class="card-body p-4">
    <div class="d-flex flex-wrap justify-content-between align-items-start gap-2 mb-4">
      <div>
        <h2 class="h5 mb-1">Pay invoice INV-0042</h2>
        <p class="small text-body-secondary mb-0">Riverside Print Co., due 14 November</p>
      </div>
      <span class="fs-4 fw-bold">&euro;48.00</span>
    </div>
    <form class="row g-3 bfm-pay-form" novalidate>
      <div class="col-12">
        <label for="bfm-pay-name" class="form-label">Name on card</label>
        <input type="text" class="form-control" id="bfm-pay-name" autocomplete="cc-name" required>
      </div>
      <div class="col-12">
        <label for="bfm-pay-number" class="form-label">Card number</label>
        <div class="input-group">
          <span class="input-group-text"><i class="bi bi-credit-card" aria-hidden="true"></i></span>
          <input type="text" class="form-control font-monospace" id="bfm-pay-number" inputmode="numeric" autocomplete="cc-number" maxlength="19" placeholder="0000 0000 0000 0000" required>
        </div>
      </div>
      <div class="col-6">
        <label for="bfm-pay-exp" class="form-label">Expiry</label>
        <input type="text" class="form-control font-monospace" id="bfm-pay-exp" inputmode="numeric" autocomplete="cc-exp" maxlength="7" placeholder="MM / YY" required>
      </div>
      <div class="col-6">
        <label for="bfm-pay-cvc" class="form-label">Security code</label>
        <input type="text" class="form-control font-monospace" id="bfm-pay-cvc" inputmode="numeric" autocomplete="cc-csc" maxlength="4" placeholder="000" required>
      </div>
      <div class="col-12 d-grid mt-4">
        <button type="submit" class="btn btn-dark btn-lg"><i class="bi bi-lock-fill me-2" aria-hidden="true"></i>Pay &euro;48.00</button>
      </div>
      <p class="col-12 small text-body-secondary text-center mb-0">Demo layout. Nothing you type here is stored or sent.</p>
    </form>
  </div>
</div>

Workshop survey with btn-check toggles

  • btn-check
  • btn-group
  • fieldset legend
  • rounded-pill
  • --bs-btn-*

Pick a rating and a few topics.

The 1 to 5 rating is a group of radio inputs with .btn-check, so it behaves like a real radio group (arrow keys, one choice) while looking like a segmented control. The topic chips are .btn-check checkboxes styled as pills. Both groups sit in a fieldset with a legend, and the purple accent comes from --bs-btn-* variables on a prefixed class.

View code
<form class="card bfm-survey border-0 shadow-sm">
  <div class="card-body p-4">
    <h2 class="h5 mb-4">How useful was the grid workshop?</h2>
    <fieldset class="mb-4">
      <legend class="fs-6 fw-semibold mb-2">Your rating</legend>
      <div class="btn-group w-100" role="group">
        <input type="radio" class="btn-check" name="bfm-sv-rate" id="bfm-sv-r1" autocomplete="off">
        <label class="btn bfm-sv-btn" for="bfm-sv-r1">1</label>
        <input type="radio" class="btn-check" name="bfm-sv-rate" id="bfm-sv-r2" autocomplete="off">
        <label class="btn bfm-sv-btn" for="bfm-sv-r2">2</label>
        <input type="radio" class="btn-check" name="bfm-sv-rate" id="bfm-sv-r3" autocomplete="off">
        <label class="btn bfm-sv-btn" for="bfm-sv-r3">3</label>
        <input type="radio" class="btn-check" name="bfm-sv-rate" id="bfm-sv-r4" autocomplete="off">
        <label class="btn bfm-sv-btn" for="bfm-sv-r4">4</label>
        <input type="radio" class="btn-check" name="bfm-sv-rate" id="bfm-sv-r5" autocomplete="off">
        <label class="btn bfm-sv-btn" for="bfm-sv-r5">5</label>
      </div>
      <div class="d-flex justify-content-between small text-body-secondary mt-1"><span>1 = not useful</span><span>5 = very useful</span></div>
    </fieldset>
    <fieldset class="mb-4">
      <legend class="fs-6 fw-semibold mb-2">What will you use at work?</legend>
      <div class="d-flex flex-wrap gap-2">
        <input type="checkbox" class="btn-check" id="bfm-sv-t1" autocomplete="off">
        <label class="btn rounded-pill bfm-sv-chip" for="bfm-sv-t1">Gutters</label>
        <input type="checkbox" class="btn-check" id="bfm-sv-t2" autocomplete="off">
        <label class="btn rounded-pill bfm-sv-chip" for="bfm-sv-t2">Row columns</label>
        <input type="checkbox" class="btn-check" id="bfm-sv-t3" autocomplete="off">
        <label class="btn rounded-pill bfm-sv-chip" for="bfm-sv-t3">Offsets</label>
        <input type="checkbox" class="btn-check" id="bfm-sv-t4" autocomplete="off">
        <label class="btn rounded-pill bfm-sv-chip" for="bfm-sv-t4">CSS Grid mode</label>
      </div>
    </fieldset>
    <div class="mb-4">
      <label for="bfm-sv-comment" class="form-label">Anything we should change? <span class="text-body-secondary">(optional)</span></label>
      <textarea class="form-control" id="bfm-sv-comment" rows="2"></textarea>
    </div>
    <button type="submit" class="btn bfm-sv-send px-4">Send feedback</button>
  </div>
</form>

Validation states, theming and disabled forms

Feedback tooltips, server-rendered is-invalid states, a form re-themed through CSS variables and a fieldset that locks every field at once.

Table booking with feedback tooltips

  • invalid-tooltip
  • position-relative
  • was-validated
  • type="date"
  • pattern

Swapping .invalid-feedback for .invalid-tooltip turns the messages into small floating labels, which need a parent with position: relative, here the .position-relative utility on each column. The tooltips overlap the next row, so the scoped CSS raises the vertical gutter through --bs-gutter-y. The phone field uses a pattern, which checkValidity() reports like required.

View code
<div class="card bfm-book border-0 shadow-sm">
  <div class="card-body p-4">
    <h2 class="h5 mb-1">Book a table at Osteria Lume</h2>
    <p class="small text-body-secondary mb-4">We hold tables for 15 minutes.</p>
    <form class="row gx-3 bfm-book-form" novalidate>
      <div class="col-sm-6 position-relative">
        <label for="bfm-book-date" class="form-label">Date</label>
        <input type="date" class="form-control" id="bfm-book-date" required>
        <div class="invalid-tooltip">Choose a date.</div>
      </div>
      <div class="col-sm-6 position-relative">
        <label for="bfm-book-time" class="form-label">Time</label>
        <select class="form-select" id="bfm-book-time" required>
          <option value="" selected disabled>Choose a time</option>
          <option>18:00</option>
          <option>19:30</option>
          <option>21:00</option>
        </select>
        <div class="invalid-tooltip">Choose a seating time.</div>
      </div>
      <div class="col-sm-6 position-relative">
        <label for="bfm-book-name" class="form-label">Name</label>
        <input type="text" class="form-control" id="bfm-book-name" autocomplete="name" required>
        <div class="invalid-tooltip">Who is the booking for?</div>
      </div>
      <div class="col-sm-6 position-relative">
        <label for="bfm-book-phone" class="form-label">Phone</label>
        <input type="tel" class="form-control" id="bfm-book-phone" autocomplete="tel" pattern="[0-9 +\(\)\-]{7,20}" required>
        <div class="invalid-tooltip">Use at least 7 digits.</div>
      </div>
      <div class="col-12 d-grid">
        <button type="submit" class="btn btn-danger btn-lg bfm-book-submit">Request booking</button>
      </div>
    </form>
  </div>
</div>

Server-side errors with is-invalid

  • is-invalid
  • is-valid
  • aria-invalid
  • aria-describedby
  • alert

Edit a red field to clear its error.

This is how a form looks when the server sends it back: .is-invalid and .is-valid go straight on the fields, no .was-validated parent needed, and each error message has an id that aria-describedby points to, next to aria-invalid="true". The script clears the error state as soon as the person edits that field.

View code
<div class="card bfm-srv border-0 shadow-sm">
  <div class="card-body p-4">
    <h2 class="h5 mb-3">Change password</h2>
    <div class="alert alert-danger d-flex gap-2 py-2" role="alert">
      <i class="bi bi-exclamation-octagon-fill flex-shrink-0 mt-1" aria-hidden="true"></i>
      <div>We could not save your new password. Fix the 2 fields marked below.</div>
    </div>
    <form class="bfm-srv-form">
      <div class="mb-3">
        <label for="bfm-srv-current" class="form-label">Current password</label>
        <input type="password" class="form-control is-invalid" id="bfm-srv-current" value="not-my-pass" autocomplete="current-password" aria-invalid="true" aria-describedby="bfm-srv-current-error">
        <div id="bfm-srv-current-error" class="invalid-feedback">That does not match the password on your account.</div>
      </div>
      <div class="mb-3">
        <label for="bfm-srv-new" class="form-label">New password</label>
        <input type="password" class="form-control is-valid" id="bfm-srv-new" value="placeholder-value-1" autocomplete="new-password" aria-describedby="bfm-srv-new-ok">
        <div id="bfm-srv-new-ok" class="valid-feedback">Strong password.</div>
      </div>
      <div class="mb-4">
        <label for="bfm-srv-repeat" class="form-label">Repeat new password</label>
        <input type="password" class="form-control is-invalid" id="bfm-srv-repeat" value="placeholder-value" autocomplete="new-password" aria-invalid="true" aria-describedby="bfm-srv-repeat-error">
        <div id="bfm-srv-repeat-error" class="invalid-feedback">This does not match the new password.</div>
      </div>
      <button type="submit" class="btn btn-primary px-4">Update password</button>
    </form>
  </div>
</div>

Pottery class booking themed with CSS variables

  • --bs-border-radius
  • --bs-body-bg
  • --bs-border-color
  • :focus box-shadow
  • form-check-input:checked

Click a field to see the themed focus ring.

Setting --bs-body-bg, --bs-body-color, --bs-border-color, --bs-border-radius and --bs-secondary-color on one wrapper re-themes every control inside it, because 5.3 form controls read those variables. The focus ring and checked color are compiled from Sass in 5.3.8 and do not read a CSS variable, so the scoped CSS sets them under the prefixed class.

View code
<form class="card bfm-clay">
  <div class="card-body p-4 p-sm-5">
    <h2 class="h4 mb-1 bfm-clay-title">Reserve a wheel-throwing class</h2>
    <p class="text-body-secondary mb-4">Clay, glazes and two firings included.</p>
    <div class="row g-3">
      <div class="col-sm-6">
        <label for="bfm-clay-name" class="form-label">Full name</label>
        <input type="text" class="form-control" id="bfm-clay-name" autocomplete="name" placeholder="Ines Duarte">
      </div>
      <div class="col-sm-6">
        <label for="bfm-clay-email" class="form-label">Email</label>
        <input type="email" class="form-control" id="bfm-clay-email" autocomplete="email" placeholder="ines@example.com">
      </div>
      <div class="col-sm-7">
        <label for="bfm-clay-class" class="form-label">Class</label>
        <select class="form-select" id="bfm-clay-class">
          <option>Beginner wheel, Sat 10:00</option>
          <option>Glazing lab, Sun 14:00</option>
          <option>Open studio, Thu 18:30</option>
        </select>
      </div>
      <div class="col-sm-5">
        <label for="bfm-clay-seats" class="form-label">Seats</label>
        <input type="number" class="form-control" id="bfm-clay-seats" min="1" max="6" value="2">
      </div>
      <div class="col-12">
        <div class="form-check">
          <input class="form-check-input" type="checkbox" id="bfm-clay-apron" checked>
          <label class="form-check-label" for="bfm-clay-apron">Lend me an apron</label>
        </div>
      </div>
      <div class="col-12 d-grid">
        <button type="submit" class="btn btn-lg bfm-clay-btn">Reserve seats</button>
      </div>
    </div>
  </div>
</form>

Locked billing details with a disabled fieldset

  • fieldset disabled
  • form-switch
  • aria-disabled
  • tabindex="-1"
  • legend

Turn on Edit details to unlock the fields.

One disabled attribute on the fieldset disables every input, select and button inside it. The link styled as a button is not a form control, so it also gets tabindex="-1" and aria-disabled="true" while locked, following the Bootstrap docs advice (the docs write aria-disabled="disabled", but ARIA only defines true and false). The Edit switch toggles fieldset.disabled and those two attributes.

View code
<form class="card bfm-lock border-0 shadow-sm">
  <div class="card-body p-4">
    <div class="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-4">
      <h2 class="h5 mb-0">Billing contact</h2>
      <div class="form-check form-switch m-0">
        <input class="form-check-input" type="checkbox" role="switch" id="bfm-lock-edit">
        <label class="form-check-label" for="bfm-lock-edit">Edit details</label>
      </div>
    </div>
    <fieldset class="bfm-lock-fields" disabled>
      <legend class="visually-hidden">Billing contact details</legend>
      <div class="row g-3">
        <div class="col-sm-6">
          <label for="bfm-lock-company" class="form-label">Company</label>
          <input type="text" class="form-control" id="bfm-lock-company" value="Tidewater Labs Ltd">
        </div>
        <div class="col-sm-6">
          <label for="bfm-lock-vat" class="form-label">VAT number</label>
          <input type="text" class="form-control font-monospace" id="bfm-lock-vat" value="GB 000 0000 00">
        </div>
        <div class="col-sm-6">
          <label for="bfm-lock-email" class="form-label">Invoice email</label>
          <input type="email" class="form-control" id="bfm-lock-email" value="accounts@tidewater.example">
        </div>
        <div class="col-sm-6">
          <label for="bfm-lock-cycle" class="form-label">Billing cycle</label>
          <select class="form-select" id="bfm-lock-cycle">
            <option>Monthly</option>
            <option selected>Yearly</option>
          </select>
        </div>
        <div class="col-12 d-flex flex-wrap gap-2 pt-2">
          <button type="submit" class="btn btn-primary">Save details</button>
          <a href="#" class="btn btn-outline-secondary bfm-lock-link" tabindex="-1" aria-disabled="true">View past invoices</a>
        </div>
      </div>
    </fieldset>
  </div>
</form>

What is a Bootstrap form?

Bootstrap doesn’t ship a form widget the way it ships a modal. What it gives you is a set of classes that sit on top of plain HTML controls, plus grid layouts and validation states. Classes such as form-control, form-select and form-check make text inputs, selects and checkboxes look the same in every browser without any custom CSS.

They build on Bootstrap’s Reboot form styles and keep controls consistent across browsers and devices (Bootstrap docs, 5.3). Eight of them do most of the work: form-control, form-label, form-select, form-check, form-switch, form-range, form-text and form-floating.

They come with the Bootstrap framework and attach to ordinary form markup, so a basic form needs no custom CSS.

The form element itself gets no default styling. Almost every control does get display: block and width: 100%, which is why fields stack vertically until a layout class says otherwise (Bootstrap docs, 5.3).

Set the right type on each input too (email, number and so on), so the browser can apply email verification and number selection.

Bootstrap’s job ends at the styling. Getting the data submitted and stored is up to the backend.

Which Bootstrap version the classes belong to

Old snippets are everywhere. Bootstrap 3 still accounts for 28.2% of the websites that use Bootstrap, roughly 4.1% of all websites (W3Techs, 28 October 2026), so markup from that generation keeps turning up next to current examples.

This article uses Bootstrap 5.3, the line the docs mark as latest, with 5.3.8 current when this was written. Bootstrap 6 Alpha 1 arrived on October 8, 2026 and rewrites several form patterns:

  • .form-select is gone, and .form-control goes on select elements instead
  • Validation is opt-in through data-bs-validate rather than .was-validated
  • Input groups no longer use .has-validation
  • Checkboxes and radios take .check or .radio directly on the input

The alpha also raises the browser floor to Chrome and Edge 130, Firefox 132 and Safari 18, where v5 reaches back to Chrome and Firefox 60 and Safari 12 (Bootstrap blog, October 8, 2026).

The maintainers call it an alpha that can still change before beta, and they plan a 5.4.0 release before announcing an end-of-life date for Bootstrap 5. For production forms I’d stay on 5.3 for now.

How to build a basic Bootstrap form

Load the Bootstrap 5.3 stylesheet, add a form element, and give each field a wrapper where a .form-label label and a .form-control input share matching for and id values. That’s a working form.

  1. Load the Bootstrap 5.3 CSS file from a CDN or npm. Form styling needs no JavaScript from the bundle.
  2. Wrap the fields in a form element and put each field in a div with .mb-3.
  3. Pair every label that carries .form-label with its input through for and id.
  4. Add .form-control to text inputs and textareas, and .form-select to selects.
  5. Put hint text in .form-text and point to it with aria-describedby on the input.
  6. Group checkboxes in .form-check, then finish with a submit button that has btn btn-primary.
<form>
  <div class="mb-3">
    <label for="email" class="form-label">Email address</label>
    <input type="email" class="form-control" id="email" aria-describedby="emailHelp">
    <div id="emailHelp" class="form-text">We only use it for the weekly digest.</div>
  </div>
  <div class="mb-3">
    <label for="plan" class="form-label">Plan</label>
    <select class="form-select" id="plan">
      <option selected>Choose a plan</option>
      <option value="1">Free</option>
      <option value="2">Team</option>
    </select>
  </div>
  <div class="form-check mb-3">
    <input type="checkbox" class="form-check-input" id="terms">
    <label class="form-check-label" for="terms">I accept the terms</label>
  </div>
  <button type="submit" class="btn btn-primary">Subscribe</button>
</form>

Spacing comes from margin utilities. Bootstrap recommends sticking to margin-bottom and using a single direction across the whole form (Bootstrap docs, 5.3).

The button is an easy place to slip. Inside a form it submits by default, so one without a type can send the form by accident.

The submit button here uses btn-primary, and any of the button variants Bootstrap offers works inside a form.

Form control classes and the elements they style

Text-like inputs and textareas get .form-control. Selects take .form-select, checkboxes, radios and switches share .form-check, and sliders use .form-range.

ControlHTML elementClassDetail
Text, email, password, textareainput, textarea.form-controlFull width, block display
Select menuselect.form-selectReplaces .custom-select from v4
Checkbox, radio, switchinput.form-check, .form-check-input, .form-check-label (plus .form-switch).form-check-inline puts options on one row
Range sliderinput type range.form-rangeReplaces .custom-range from v4
File and colorinput type file or color.form-control (plus .form-control-color)Color input needs type="color"

Text-like controls accept the standard HTML input types, including email, password, number and date. More detail on them sits in the guide to Bootstrap input fields.

Height changes with .form-control-sm or .form-control-lg, and selects use .form-select-sm and .form-select-lg.

Put the disabled attribute on a control, or on a fieldset to disable everything inside it. Button-like links such as a.btn stay focusable in a disabled fieldset, so they need tabindex="-1" and aria-disabled (Bootstrap docs, 5.3). The docs write aria-disabled="disabled", but ARIA only defines true and false, so use aria-disabled="true".

The readonly attribute keeps the value visible and blocks edits. Swap .form-control for .form-control-plaintext to drop the field styling and keep the margin and padding.

Switches have one dated quirk. Safari 17.4 and later supports a native switch attribute that adds haptic feedback on iOS, and Bootstrap’s custom-styled switches look the same with or without it (Bootstrap docs, 5.3).

Input groups for icons and buttons

An input group attaches text or a button to the side of a field, as long as both sit inside .input-group. Icons count as text here.

  • .input-group-text styles a prefix or suffix such as @ or $
  • Buttons go straight into the group, no wrapper needed
  • Resizing the whole group takes .input-group-sm or .input-group-lg
  • Several inputs and several addons can share one group

Bootstrap 5 removed the v4 wrappers .input-group-prepend and .input-group-append, so old markup needs flattening. Icons from the Bootstrap icon set fit inside .input-group-text without extra classes.

Validation styles also only work on input groups that hold a single input (Bootstrap docs, 5.3), which catches people out.

Which layout fits: vertical, horizontal, inline or floating labels?

Most forms are fine with the default vertical stack. Horizontal grids suit short labels on wide screens, inline rows work for compact filters, and floating labels help when a short form is short on vertical space.

LayoutKey classesBest forWatch out for
Vertical.mb-3 per fieldLogin, short formsTall on long forms
Horizontal.row, .col-sm-*, .col-form-labelSettings, admin screensMore markup per field
Inline.row-cols-lg-auto, .g-3, .col-12Search, filtersHidden labels still needed
Floating labels.form-floatingShort mobile formsPlaceholder required, input first

Vertical form

Vertical is the default, and it needs nothing beyond .mb-3.

There’s no grid markup and no breakpoints to manage, which keeps the class count low. The catch is length. A form with many fields turns into one long column.

Horizontal form

  • Put .row on each form group
  • Give the label .col-sm-2 and .col-form-label, and wrap the control in .col-sm-10
  • Use .col-form-label-sm or .col-form-label-lg so the label matches .form-control-sm or .form-control-lg
  • Wrap radio groups in a fieldset with .row, and use a legend with .col-form-label

The columns come from the grid system, so the usual breakpoints apply. Below the chosen one, the labels sit above the controls.

Labels line up neatly with their fields, though you pay for that with more markup than a vertical form.

Inline form

Inline forms in v5 are a grid arrangement, not a class, because .form-inline was removed.

Putting row row-cols-lg-auto g-3 align-items-center on the form lines the fields up in one row from the lg breakpoint up, and each field sits in a .col-12 wrapper. Hide the labels with .visually-hidden instead of deleting them, because every control still needs a name for assistive technology.

Below lg, the .col-12 wrappers stack the fields, which keeps the row usable on a small viewport. Compact and easy to scan. But long labels and many fields wrap into a second row.

Floating labels

Floating labels had a rough moment in 2025. Bootstrap 5.3.5 (April 4, 2025) was a hotfix for an Autoprefixer regression that left them permanently floated in Firefox (Bootstrap blog, 2025).

The labels are CSS-only and detect an empty field through :placeholder-shown, which sets strict rules:

  • a non-empty placeholder on every input (it stays invisible)
  • the input before the label in the markup, because the CSS uses a sibling selector
  • an explicit height on textareas, never the rows attribute
  • selects work but always show the label floated, and selects with size or multiple are not supported

Those constraints come from the Bootstrap 5.3 docs. Nielsen Norman Group credits the pattern with saving space on mobile and keeping the label visible while people type, and rates it better than using a placeholder as the label (NN/g, 2014, reviewed 2018).

It still ranks second to a label placed outside the field when screen space allows. Fields holding text look less noticeable and can pass for prefilled, and some assistive technology reads placeholders unreliably. NN/g points to Warby Parker’s site as its example of the pattern done well.

Bootstrap’s own accessibility note lands in the same place: visible label text is generally the best approach (Bootstrap docs, 5.3). So the two sources agree.

My rule of thumb is floating labels for a short form like a login, and labels above the fields for anything longer or rarely used.

Contact, login and registration forms: what each one needs

These forms ask for different things. Login is two fields in a vertical stack. Contact puts a name-and-email row above a textarea, and registration wants a two-column grid with a required terms checkbox and validation.

Login form

A vertical stack with two fields works, plus a remember-me checkbox. The submit button goes full width.

Each field gets its own .mb-3 wrapper. Use an email or username input and a type="password" input, with matching autocomplete values so password managers can fill them. A .form-check handles remember me, and a .d-grid wrapper stretches the button.

Floating labels fit here because the form is short. More variations live in the guide to building a login form in Bootstrap.

Contact form

A contact form pairs name and email in one row and gives the message a full-width textarea.

<form class="row g-3 needs-validation" novalidate>
  <div class="col-md-6">
    <label for="name" class="form-label">Name</label>
    <input type="text" class="form-control" id="name" required>
  </div>
  <div class="col-md-6">
    <label for="mail" class="form-label">Email</label>
    <input type="email" class="form-control" id="mail" required>
  </div>
  <div class="col-12">
    <label for="msg" class="form-label">Message</label>
    <textarea class="form-control" id="msg" rows="5" required></textarea>
  </div>
  <div class="col-12">
    <button type="submit" class="btn btn-primary">Send</button>
  </div>
</form>

The novalidate attribute turns off the browser’s default error bubbles so custom styles can take over, and required marks the fields that must be filled.

For a fuller build, see this walkthrough of a contact form in Bootstrap.

Registration form

  • First name and last name as .col-md-6 pairs
  • Email at full width
  • Password and confirm password side by side
  • A required terms checkbox in .form-check
  • An input group, if the form needs a username prefix or a show-password button

Registration forms collect the most fields, so clear validation feedback matters most here.

How Bootstrap form validation works

Bootstrap doesn’t do the checking itself. The browser handles that through the HTML5 constraint validation API, and Bootstrap paints the result on inputs, textareas, selects and checks (Bootstrap docs, 5.3).

On submit, a script adds .was-validated to the form, which turns the browser’s :valid and :invalid states into green and red styling. .valid-feedback or .invalid-feedback elements supply the text.

Which route you take depends mostly on how much control you want over the error messages.

Browser default validation

Add required or a type such as email and stop there. No novalidate, no script, no .was-validated.

That means zero JavaScript, and the docs recommend it, alongside server-side classes, as the accessible alternative to custom styles. The downside is that the bubbles look different in every browser and cannot be styled with CSS, although the wording can still be changed with setCustomValidity.

W3C’s Understanding page for WCAG 2.2 success criterion 3.3.1 covers native validation and points out its drawbacks. Depending on the browser, messages vanish, ignore zoom, read generically, and expose only the first error per submission, so developers have to judge whether it is accessibility supported (W3C Understanding docs, updated June 2026).

Custom styles with .was-validated

Custom styles need novalidate on the form and a hook class for the script to find. Each control also gets feedback elements beside it.

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

The handler is a few lines of JavaScript that blocks submission when checkValidity() returns false and always adds the class, so valid fields turn green too.

The CSS never mentions .needs-validation. It is only the selector the starter script looks for.

For dynamic submissions through Ajax, remove .was-validated after the response to reset the form’s appearance.

You get full control over messages and tooltips, and over both states. The same docs page opens with a warning, though, that custom validation styles and tooltips are not exposed to assistive technologies.

Server-side classes

  • .is-invalid and .is-valid work without a .was-validated parent
  • .invalid-feedback is supported alongside them
  • The backend renders the classes after its own checks

Point aria-describedby at the feedback id on every invalid field. The attribute takes several ids, so a field can reference an addon or hint and its error together (Bootstrap docs, 5.3).

This is the other route the docs treat as accessible. The cost is a round trip for every error.

Feedback messages and tooltips

For plain text under the control, .valid-feedback and .invalid-feedback do the job. .valid-tooltip and .invalid-tooltip float a styled bubble instead, and the parent needs position: relative (the .position-relative utility does it).

Bootstrap 5.3.0 added local CSS variables for validation colors, --bs-form-valid-color and --bs-form-invalid-color among them, and they adapt to dark mode.

The v6 alpha moves the styling to the :user-invalid pseudo-class, which activates only after a user interacts with a field (Bootstrap migration guide, v6.0.0-alpha.1).

Making a Bootstrap form accessible

Bootstrap handles the visual layer and none of the error wiring. Every field still needs an accessible name and a linked hint or error message, and anything offering a choice needs a fieldset. For a broader checklist, see this guide to building accessible forms.

The label numbers are not great. WebAIM found missing form input labels on 51% of the top million home pages in February 2026, up from 48.2% a year earlier.

The 240,869 pages detected with Bootstrap averaged 63.3 errors against 56.1 for the whole sample. WebAIM adds that extra errors cannot always be pinned on the technology (WebAIM Million, 2026).

Names, labels and groups

A label is the simplest source of an accessible name. Where none fits, Bootstrap lists a .visually-hidden label, aria-labelledby, a title or aria-label, and warns that assistive technology can fall back to the placeholder when none exists.

Radio and checkbox sets belong in a fieldset with a legend, because user agents often read the legend before each option’s label (W3C technique H71, updated January 2026). The technique is optional when every option’s label already says it all.

WCAG 3.3.2 requires labels or instructions wherever input is requested, while correct association is judged separately under 1.3.1 (W3C Understanding docs). That makes it a Level A rule.

Errors and required fields

Orange’s accessibility guidelines, in a form article built on Boosted, its Bootstrap-based library, walk through the error routine step by step (Orange, updated June 2026):

  1. Set aria-invalid="true" on each field in error, next to .is-invalid
  2. Write the message in plain text that names the field and the fix, never color alone
  3. Link it with aria-describedby or aria-labelledby
  4. Move focus to the first field in error

Orange also keeps the submit button active in all circumstances and calls a disabled-until-valid button a bad idea.

The required attribute tells assistive technology a field is mandatory, and the visual marker cannot rely on color alone. An asterisk needs a sentence at the top of the form that explains it.

ARIA and native elements

Use the native element first. WebAIM’s 2026 data shows pages with ARIA averaged 59.1 detected errors, against 42.0 without it, and the pages were also more complex, so ARIA is not necessarily the cause.

A label element needs no ARIA attributes at all, which leaves aria-describedby for hints and errors.

Why is a Bootstrap form not working?

Most broken Bootstrap forms come down to a handful of mistakes. A missing novalidate, a script that never runs, .was-validated hard-coded in the markup, feedback that isn’t a sibling placed after its control, or an input group without .has-validation.

SymptomCauseFix
Browser bubbles, no Bootstrap stylingForm lacks novalidateAdd it, because the browser blocks submission before the script runs
Nothing happens on submitScript missing, or its selector matches no formLoad it after the markup and match the hook class
Every empty required field is red on load.was-validated is in the HTMLAdd it only inside the submit handler
Feedback text never showsFeedback is not a sibling that follows its controlPlace it after the control inside the same parent, because the CSS uses the sibling selector
Addon corners or feedback look offInput group has no .has-validationAdd it, and with floating labels show the feedback through JavaScript

A few smaller traps come from the docs. Validation icons on selects appear only with .form-select, not .form-control, and in v5 they skip selects with multiple.

  • A label placed before its control cannot be styled by the validation CSS without custom JavaScript
  • Bootstrap 3 snippets using .has-error or .help-block style nothing, because v5 keys off .was-validated, :valid, :invalid and the .is-* classes

Rules that span fields, such as a password match, sit outside what required and type checks cover. Use setCustomValidity or the server-side classes.

What changed in Bootstrap forms from version 4 to version 5

Bootstrap 5 dropped the v4 layout and custom control classes in favor of margin utilities, the grid and one set of consolidated components. It also made .form-label mandatory.

Bootstrap 4Bootstrap 5Change
.form-group, .form-row, .form-inline.mb-3, .row with .g-*, .row-cols-*Layout moved to utilities and the grid
.custom-control (checkbox, radio, switch).form-check, .form-check.form-switchNative and custom merged
.custom-select, .custom-range, .form-control-range.form-select, .form-rangeOne class per control
.custom-file, .form-control-file.form-control on input type="file"Native variants dropped
.input-group-prepend, .input-group-appendDirect children of .input-groupWrappers removed
  • Every label now needs .form-label
  • .form-text no longer sets display, so the HTML element decides inline or block
  • Controls use min-height instead of a fixed height where possible
  • Floating labels are new in 5.0, promoted from a v4-era example
  • Support for Internet Explorer 10 and 11 is gone

Those changes come from the official v5 migration notes. Treat them as a checklist when upgrading old markup.

What the 5.x releases added

Version 5.2.0 refined the border radius on form controls and added the .form-check-reverse modifier. Then 5.3.0 moved form controls, checks and switches to CSS variables for color modes, and validation colors became color-mode aware (Bootstrap migration docs).

Bootstrap 4 reached end of life on January 1, 2023, a date the team announced in its July 19, 2022 post for v4.6.2. Old v4 forms still render, but they no longer get fixes.

Bootstrap Form FAQ

Does a Bootstrap form need jQuery?

No. Bootstrap 5.0 dropped jQuery and rewrote its plugins in plain JavaScript.

Form styling is pure CSS, and the validation starter script relies on standard browser methods such as checkValidity().

Can Bootstrap forms work in React, Vue, Rails or Django?

Yes. Wrapper libraries render the same Bootstrap classes: React-Bootstrap v2 for React, BootstrapVueNext for Vue, the bootstrap_form gem for Rails and django-bootstrap5 for Django.

Match the wrapper’s version to your Bootstrap version, because v4 and v5 class names differ.

How do you customize Bootstrap form styles?

Set Sass variables such as $input-btn-padding-y and $input-btn-padding-x, which buttons and form controls share. Place them after the functions import and before the variables import.

For runtime tweaks, the 5.3 validation colors also respond to CSS variables like –bs-form-invalid-color.

Do Bootstrap forms support dark mode?

Yes, from Bootstrap 5.3. Set data-bs-theme=”dark” on the html element or on any wrapper, a single form included, and the controls and validation colors follow it (Bootstrap docs, 5.3).

Is there a drag-and-drop Bootstrap form builder?

Not in the official docs. They offer markup examples for each control and layout, validation states included, but no visual builder.

Third-party generators exist. Check their class names against 5.3, since older ones can emit v3 or v4 classes.

What Happens After a Bootstrap Form Is Submitted

Once submitted, the field values go to the URL in the form’s action attribute and Bootstrap does nothing more. Validation belongs to server code, along with storage and whatever response comes back.

OWASP’s Input Validation Cheat Sheet calls client-side checks bypassable and gives them one job, immediate feedback.

  • Validate every field on the server
  • Return failures with .is-invalid and aria-describedby
  • Add the .was-validated script last

That order follows what each layer guarantees: only the server check holds when a request skips the browser. OWASP also lists drop-down values among the inputs that need an exact membership check.

This holds for Bootstrap 5.3.8, verified on October 28, 2026. A stable Bootstrap 6 release would change the client hooks and leave the server-side rule untouched.