Forms: Validation States & Feedback
|
This section documents Bootstrap 5.x as implemented by the official Bootstrap project. No specific patch version is pinned. Unlike the other reference sections on this site, no single reference book underpins it: the content was generated with the assistance of AI from general knowledge of Bootstrap, and should be verified against the current official documentation at getbootstrap.com/docs before relying on it in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
Bootstrap layers a set of validation-state classes and feedback elements on top of the native browser
validation described in
Form Accessibility & Validation — the required
attribute, type="email", pattern, :valid/:invalid, and the JavaScript Constraint Validation API all
still work exactly as documented there. What Bootstrap adds is purely visual and structural: consistent
colors/icons for valid and invalid fields, and a standard place to put the feedback text explaining why a
field failed. This page covers .is-valid/.is-invalid, .valid-feedback/.invalid-feedback, and the two
ways of triggering them — browser-native constraint validation and fully custom JavaScript validation — as
well as Forms for the controls being validated.
The validation classes
Two pairs of classes drive Bootstrap’s validation styling:
| Class | Effect |
|---|---|
|
Applied directly to a |
|
Same mechanism, red border and an exclamation-mark icon instead. |
|
A block of text (typically a |
|
Same mechanism as |
Both feedback classes rely on being an adjacent sibling of the state-classed control in the DOM — Bootstrap’s
CSS uses a ~ sibling selector to reveal them, so moving the feedback <div> somewhere else in the markup (for
example, inside a wrapping <div> around only the control) silently breaks the reveal:
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control is-invalid" id="username" value="a">
<div class="invalid-feedback">
Username must be at least 3 characters.
</div>
</div>
<div class="mb-3">
<label for="display-name" class="form-label">Display name</label>
<input type="text" class="form-control is-valid" id="display-name" value="Jane">
<div class="valid-feedback">
Looks good!
</div>
</div>
If a control sits inside an .input-group, the feedback <div> must stay inside the group, as a sibling of
the control that triggers it — moving it after the group’s closing tag breaks the very ~ sibling selector
this section opened with, since the feedback element would then be a sibling of .input-group rather than of
the state-classed control itself:
<div class="input-group has-validation">
<span class="input-group-text" id="handle-addon">@</span>
<input type="text" class="form-control is-invalid" aria-describedby="handle-addon">
<div class="invalid-feedback">
Please choose a username.
</div>
</div>
.has-validation on the .input-group itself is required in this case — without it, Bootstrap’s own
input-group border-radius CSS overlaps the feedback text’s position.
Browser-native validation styling with .was-validated
Rather than hand-adding .is-valid/.is-invalid to every field, Bootstrap can drive the same visual states
straight off the browser’s own :valid/:invalid pseudo-classes (see
Form Accessibility & Validation for how those are
computed from required, type, pattern, and friends). The trigger is the .was-validated class on the
<form>, combined with disabling the browser’s own bubble UI via novalidate:
<form class="row g-3 needs-validation" novalidate>
<div class="col-md-6">
<label for="first-name-v" class="form-label">First name</label>
<input type="text" class="form-control" id="first-name-v" required>
<div class="valid-feedback">Looks good!</div>
<div class="invalid-feedback">First name is required.</div>
</div>
<div class="col-md-6">
<label for="email-v" class="form-label">Email</label>
<input type="email" class="form-control" id="email-v" required>
<div class="valid-feedback">Looks good!</div>
<div class="invalid-feedback">Please enter a valid email address.</div>
</div>
<div class="col-12">
<button class="btn btn-primary" type="submit">Submit</button>
</div>
</form>
// Bootstrap does not add this behavior automatically -- it must be wired up per form.
(() => {
const forms = document.querySelectorAll(".needs-validation");
Array.from(forms).forEach((form) => {
form.addEventListener("submit", (event) => {
if (!form.checkValidity()) {
event.preventDefault();
event.stopPropagation();
}
form.classList.add("was-validated");
}, false);
});
})();
novalidate on the <form> suppresses the browser’s own validation bubbles so only Bootstrap’s styling shows;
form.checkValidity() (from the Constraint Validation API) still runs the real native checks underneath, it is
only the presentation that is suppressed. Adding .was-validated to the form is what turns on the CSS rules
that map :valid/:invalid to .is-valid-/.is-invalid-equivalent styling — before submission is attempted,
fields show no state at all, which avoids painting an untouched required field red before the user has had a
chance to fill it in.
Fully custom validation
For rules the browser cannot express natively — a password-confirmation match, an async username-availability
check, a business-specific format — apply .is-valid/.is-invalid directly from JavaScript instead of relying
on .was-validated. This is the same pattern as setCustomValidity in plain HTML forms, just paired with
Bootstrap’s own classes rather than the browser’s native validation bubble:
<div class="mb-3">
<label for="password-c" class="form-label">Password</label>
<input type="password" class="form-control" id="password-c">
</div>
<div class="mb-3">
<label for="confirm-c" class="form-label">Confirm password</label>
<input type="password" class="form-control" id="confirm-c">
<div class="invalid-feedback">Passwords do not match.</div>
</div>
const password = document.getElementById("password-c");
const confirm = document.getElementById("confirm-c");
function validateConfirm() {
const matches = confirm.value === password.value;
confirm.classList.toggle("is-invalid", !matches);
confirm.classList.toggle("is-valid", matches && confirm.value !== "");
}
password.addEventListener("input", validateConfirm);
confirm.addEventListener("input", validateConfirm);
Because this approach never touches required/pattern/setCustomValidity, form.checkValidity() knows
nothing about it — a submit handler relying purely on custom classes must check
confirm.classList.contains("is-invalid") itself (or block submission directly) rather than trusting native
constraint validation to catch the mismatch.
Choosing between the two approaches
-
Prefer the
.was-validated/ native-constraint route wheneverrequired,type,pattern,min/max, orminlength/maxlengthalready express the rule — it needs no per-field JavaScript and stays in sync with whatever the Constraint Validation API reports. -
Reach for manually toggled
.is-valid/.is-invalidonly for rules the browser genuinely cannot express, and combine it withsetCustomValidity(see Form Accessibility & Validation) if the field should also block a plainform.checkValidity()/requestSubmit()call, not just look wrong. -
Either way, always pair the color-coded border with real feedback text — color alone is not perceivable to a colorblind user or a screen-reader user, both of whom rely on the
.invalid-feedback/.valid-feedbacktext actually being present and shown.
See the official Validation documentation for the full set of supported selectors and browser-compatibility notes.