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

.is-valid

Applied directly to a .form-control/.form-select/.form-check-input. Colors the border and, for text-like controls, adds a green checkmark icon.

.is-invalid

Same mechanism, red border and an exclamation-mark icon instead.

.valid-feedback

A block of text (typically a <div> right after the control) that is hidden by default and shown only when its preceding sibling carries .is-valid.

.invalid-feedback

Same mechanism as .valid-feedback, shown when the preceding sibling carries .is-invalid.

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 whenever required, type, pattern, min/max, or minlength/maxlength already 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-invalid only for rules the browser genuinely cannot express, and combine it with setCustomValidity (see Form Accessibility & Validation) if the field should also block a plain form.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-feedback text actually being present and shown.

See the official Validation documentation for the full set of supported selectors and browser-compatibility notes.