Forms in depth

Move past basic inputs: floating labels, input groups, grid-aligned layouts, validation states driven by real server responses, and the accessibility details that make them usable.

Controls, sizing and floating labels

<div class="row g-3">
  <div class="col-md-6">
    <div class="form-floating">
      <input type="email" class="form-control" id="email" name="email" placeholder="name@example.com" required>
      <label for="email">Email address</label>
    </div>
  </div>

  <div class="col-md-6">
    <div class="form-floating">
      <select class="form-select" id="plan" name="plan">
        <option value="" selected>Choose a plan</option>
        <option value="starter">Starter</option>
        <option value="team">Team</option>
      </select>
      <label for="plan">Plan</label>
    </div>
  </div>

  <div class="col-12">
    <div class="input-group">
      <span class="input-group-text">https://</span>
      <input type="text" class="form-control" id="host" name="host" aria-label="Hostname">
      <span class="input-group-text">.example.com</span>
    </div>
  </div>

  <div class="col-md-4">
    <input type="text" class="form-control form-control-sm" placeholder="Small (.form-control-sm)">
  </div>
  <div class="col-md-4">
    <input type="text" class="form-control" placeholder="Default">
  </div>
  <div class="col-md-4">
    <input type="text" class="form-control form-control-lg" placeholder="Large (.form-control-lg)">
  </div>
</div>
  • Floating labels require the input to come before the label and to have a placeholder. Remove either and the label collapses into the field.
  • Use form-select, not form-control, on a <select> — the styling of the caret depends on it.
  • input-group puts text, buttons or selects flush with a control. Only the middle element takes the sizing class.
  • Never put a label inside an input-group; use aria-label on the control or a visible label above the group.
<div class="form-check">
  <input class="form-check-input" type="checkbox" value="news" id="news" checked>
  <label class="form-check-label" for="news">Send me product updates</label>
</div>

<div class="form-check form-switch">
  <input class="form-check-input" type="checkbox" role="switch" id="beta" aria-describedby="betaHelp">
  <label class="form-check-label" for="beta">Join the beta channel</label>
</div>

<div class="btn-group" role="group" aria-label="Billing period">
  <input type="radio" class="btn-check" name="period" id="monthly" value="monthly" checked>
  <label class="btn btn-outline-primary" for="monthly">Monthly</label>
  <input type="radio" class="btn-check" name="period" id="yearly" value="yearly">
  <label class="btn btn-outline-primary" for="yearly">Yearly</label>
</div>

Validation that reflects the server

Bootstrap's validation classes are presentation only: nothing is checked for you. The two legitimate triggers are the browser's Constraint Validation API on the client and a 422 response on the server, and both end up setting the same is-invalid / is-valid classes.

const form = document.querySelector('#signup');

form.addEventListener('submit', async (event) => {
  event.preventDefault();

  // 1. client-side pass: ask the browser, then let Bootstrap style the result
  if (!form.checkValidity()) {
    form.classList.add('was-validated');
    form.querySelector(':invalid')?.focus();
    return;
  }

  // 2. server-side pass: the server is the authority on uniqueness and policy
  const res = await fetch(form.action, { method: 'POST', body: new FormData(form) });
  if (res.status === 422) {
    const { errors } = await res.json();          // { email: 'Already registered' }
    for (const field of form.elements) {
      const message = errors[field.name];
      field.classList.toggle('is-invalid', Boolean(message));
      const feedback = field.parentElement.querySelector('.invalid-feedback');
      if (feedback && message) feedback.textContent = message;
      if (message) field.setAttribute('aria-invalid', 'true');
    }
    return;
  }
  window.location.assign('/welcome');
});
<div class="col-md-6">
  <label for="slug" class="form-label">Workspace URL</label>
  <input type="text" class="form-control" id="slug" name="slug" required
         aria-describedby="slugHelp slugError">
  <div id="slugHelp" class="form-text">Lowercase letters, numbers and hyphens.</div>
  <div id="slugError" class="invalid-feedback">That workspace name is already taken.</div>
</div>
⚠️
Adding was-validated to the form styles every field, including ones the user has not reached. Apply it on the first submit, and for live feedback switch to per-field classes on blur instead — otherwise a blank form turns red before anyone has typed.

Layout, help text and disabled controls

GoalMarkupNote
Label above the field<label class="form-label">The predictable default
Label beside the fieldrow + col-form-labelNeeds a column wrapper for the control too
Hint under the fieldform-textLink it with aria-describedby
Error messageinvalid-feedbackOnly shows when a sibling has is-invalid
Success messagevalid-feedbackOnly shows when a sibling has is-valid
<form class="row g-3 align-items-center">
  <div class="col-auto">
    <label class="col-form-label" for="qty">Quantity</label>
  </div>
  <div class="col-auto">
    <input type="number" class="form-control" id="qty" name="qty" min="1" max="99" value="1"
           aria-describedby="qtyHelp">
  </div>
  <div class="col-auto">
    <span id="qtyHelp" class="form-text">Maximum 99 per order.</span>
  </div>
</form>

<fieldset disabled>
  <legend class="fs-6">Frozen at checkout</legend>
  <input class="form-control" placeholder="Disabled because it is inside a disabled fieldset">
</fieldset>
  • A <fieldset disabled> disables every control inside it, including links that behave as buttons — one attribute instead of a loop.
  • Disabled controls are not submitted. If the value must reach the server, use readonly instead.
  • form-text is not automatically announced; the aria-describedby link is what connects it to the input for screen readers.
  • novalidate on the form suppresses the native bubbles so Bootstrap's own messaging is the only feedback the user sees.

FAQ

Why do my floating labels look broken?
Almost always one of three causes: the label is not immediately after the input, the input has no placeholder, or the wrapper is not form-floating. Floating labels also cannot be combined with input-group.
Should I rely on Bootstrap's validation or write my own?
Use the classes as the visual layer, but drive them from something real — the Constraint Validation API or a server response. Roll your own logic when you need async checks such as username availability, since the native API has none.

Navbar, modal and forms Accessibility with Bootstrap

Last refreshed 2026-09-18.