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="[email protected]" 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, notform-control, on a<select>— the styling of the caret depends on it. input-groupputs text, buttons or selects flush with a control. Only the middle element takes the sizing class.- Never put a
labelinside aninput-group; usearia-labelon 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
| Goal | Markup | Note |
|---|---|---|
| Label above the field | <label class="form-label"> | The predictable default |
| Label beside the field | row + col-form-label | Needs a column wrapper for the control too |
| Hint under the field | form-text | Link it with aria-describedby |
| Error message | invalid-feedback | Only shows when a sibling has is-invalid |
| Success message | valid-feedback | Only 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
readonlyinstead. form-textis not automatically announced; thearia-describedbylink is what connects it to the input for screen readers.novalidateon 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.
Related
Navbar, modal and forms Accessibility with Bootstrap
Last refreshed 2026-09-18.