Form Validation Benchmark

Bootstrap .was-validated / .is-invalid JS vs Native :user-valid / :user-invalid

Bootstrap JS

Requires JS to add .was-validated on form submit, or .is-valid / .is-invalid per field. Generated by form-validation-state() mixin × 6 element types × 2 states × 2 selector paths.

Required field.
Looks good!
Native Browser API

:user-valid / :user-invalid activate automatically after user interaction (type, blur). No wrapper class, no JS event listeners, no classList manipulation.

Required field.
Looks good!
No submit needed — validation activates on interaction.
Initializing... 0%

Results

Metric Bootstrap .was-validated Native :user-valid
Setup time (event listeners for 50 fields) - -
Form-level toggle (100 cycles × .was-validated) - -
Field-level toggle (100 cycles × 50 × .is-invalid) - -
Instance memory cost - -
JS code required
User-written validation script
- -
CSS output size - -
Bootstrap JS 50 fields — .was-validated + .is-invalid
Native Browser API 50 fields — :user-valid / :user-invalid

Why .was-validated exists — :valid fires on page load

:valid / :invalid match immediately on page load — before the user touches anything. Bootstrap invented the .was-validated wrapper + JS to prevent premature validation styles. :user-valid / :user-invalid solve this natively.

:invalid — red border on page load
This red border appears on page load. That’s why Bootstrap needs .was-validated + JS.
:user-invalid — waits for interaction
Shows only after you interacted.
Looks good!

Code Comparison — Bootstrap v6 SCSS vs Native

Bootstrap v6: 2 SCSS files + JS trigger
/* scss/mixins/_forms.scss — selector mixin */
@mixin form-validation-state-selector($state) {
  .was-validated &:#{$state},
  &.is-#{$state} {
    @content;
  }
}

/* scss/forms/_validation.scss — state mixin */
@mixin form-validation-state($state, $color, $icon, ...) {
  .#{$state}-feedback { display: none; color: $color; }
  .#{$state}-tooltip { display: none; ... }

  @include form-validation-state-selector($state) {
    ~ .#{$state}-feedback { display: block; }
  }

  .form-control {
    @include form-validation-state-selector($state) {
      border-color: $border-color;
      background-image: escape-svg($icon);
      &:focus { box-shadow: $focus-box-shadow; }
    }
  }
  textarea.form-control { ... }
  .form-select { ... }
  .form-control-color { ... }
  .form-check-input {
    @include form-validation-state-selector($state) {
      border-color: $border-color;
      &:checked { background-color: $color; }
      ~ .form-check-label { color: $color; }
    }
  }
  .input-group { ... z-index stacking ... }
}

/* Loop × 2 states */
@each $state, $data in $form-validation-states {
  @include form-validation-state($state, $data...);
}

/* User must write JS to trigger: */
form.addEventListener('submit', e => {
  if (!form.checkValidity()) e.preventDefault();
  form.classList.add('was-validated');
});
Native: :user-valid / :user-invalid (zero JS)
/* Replace entire mixin with direct pseudo-classes */
.form-control:user-valid,
.form-select:user-valid {
  border-color: var(--bs-success);
  background-image: url("...checkmark...");
}
.form-control:user-invalid,
.form-select:user-invalid {
  border-color: var(--bs-danger);
  background-image: url("...x-icon...");
}

.form-check-input:user-valid {
  border-color: var(--bs-success);
}
.form-check-input:user-valid:checked {
  background-color: var(--bs-success);
}
.form-check-input:user-invalid {
  border-color: var(--bs-danger);
}

:user-invalid ~ .invalid-feedback {
  display: block;
}
:user-valid ~ .valid-feedback {
  display: block;
}

/* No .was-validated wrapper.
   No .is-valid / .is-invalid classes.
   No form-validation-state-selector() mixin.
   No addEventListener('submit').
   No classList.add().
   Browser activates after user interaction.

   Replaces:
   forms/_validation.scss (187 lines)
   mixins/_forms.scss (163 lines)
   ~60-80 lines generated CSS eliminated */
Implementation: :user-valid / :user-invalid (key code)
<!-- HTML: standard Bootstrap form, NO .was-validated, NO JS -->
<form novalidate>
  <input type="email" class="form-control" required>
  <div class="invalid-feedback">Please enter a valid email.</div>
  <div class="valid-feedback">Looks good!</div>

  <select class="form-select" required>...</select>
  <input type="checkbox" class="form-check-input" required>
</form>

<style>
  .form-control:user-valid  { border-color: var(--bs-success); background-image: url("..."); }
  .form-control:user-invalid { border-color: var(--bs-danger);  background-image: url("..."); }
  .form-check-input:user-valid { border-color: var(--bs-success); }
  .form-check-input:user-invalid { border-color: var(--bs-danger); }
  :user-invalid ~ .invalid-feedback { display: block; }
  :user-valid ~ .valid-feedback { display: block; }
</style>