Vivian Voss

The Form the Browser Already Validated

html css javascript performance

Stack Patterns ■ Episode 8

The average React project installs a validation library before a single input field exists in the markup. To verify what, precisely? That an email contains an @. That a required field has a value. That a name contains letters.

The browser has done this since 2014. Rather quietly, given how few seem to have noticed.

The Pattern

HTML5 introduced constraint validation as part of the W3C Recommendation of 28 October 2014. The mechanism is declarative: you describe the constraints in HTML attributes, and the browser enforces them on submit.

<form>
  <input type="email" required>
  <input type="text" required
    minlength="2" pattern="[A-Za-z\s\-']+">
  <input type="url">
  <button type="submit">Send</button>
</form>

Required fields must have values. Email fields must contain emails. Pattern fields must match the regex. The browser checks on submit, blocks if invalid, tells the user what went wrong.

No onChange. No useState. No schema object. No bundle size.

Bundle cost of form validation Native HTML validation required, type, pattern, minlength, maxlength 0 KB Typical React validation stack react-hook-form + Zod + custom hooks ~40 KB 126M weekly npm downloads combined

The UX Objection, Resolved

"Native validation shows errors on page load." Fair criticism. It did. The :invalid pseudo-class matched the moment the DOM rendered, painting every empty required field red before the user had typed a single character. It was, by any measure, rude.

Then, in 2023, :user-invalid arrived.

input:user-invalid {
  outline: 2px solid #c00;
}
input:user-valid {
  outline: 2px solid #0a0;
}

:user-invalid fires only after the user has interacted with the field. Load the page: pristine. Tab through empty fields: pristine. Type something, leave the field: now it validates. The single biggest UX complaint about native validation, resolved in pure CSS.

:user-invalid browser support Firefox 88 2021 Safari 16.5 Jun 2023 Chrome 119 Nov 2023 Baseline: every modern browser since November 2023 89.75% global support. 97.54% for the Constraint Validation API itself.

Custom Error Messages

"But I need custom error messages." One line:

input.setCustomValidity("Please use your work email.");

The Constraint Validation API ships with every browser. The element.validity object exposes eleven boolean properties: valueMissing, typeMismatch, patternMismatch, tooShort, tooLong, rangeOverflow, rangeUnderflow, stepMismatch, badInput, customError, and valid. Each tells you precisely what failed. All free. All native. All shipped in 2014.

For those who want a complete example with custom messages, the pattern is straightforward:

const email = document.querySelector('[type="email"]');
email.addEventListener('invalid', () => {
  email.setCustomValidity(
    email.validity.typeMismatch
      ? "That does not look like an email."
      : "Email is required."
  );
});
email.addEventListener('input', () => {
  email.setCustomValidity("");
});

Two event listeners. One for the error, one to clear it when the user corrects the input. The browser handles the rest.

Cross-Field Validation

"But what about cross-field validation? What if the submit button should disable until the entire form is valid?"

form:has(:invalid) [type="submit"] {
  opacity: 0.4;
  pointer-events: none;
}

If any field inside the form is invalid, the submit button dims. No JavaScript. CSS checks on every reflow. The :has() pseudo-class turns the parent into a state machine. The form observes its own children and reacts accordingly.

The native validation stack HTML required, type, pattern, minlength, maxlength CSS :user-invalid, :user-valid, :has(:invalid) JS setCustomValidity() for custom messages (optional) Server Uniqueness, authorisation, business rules (always needed)

When This Is Not Enough

"Does this username already exist?" Fair question. That requires a server round-trip. No amount of client-side validation can answer it. One fetch call:

const r = await fetch(`/api/check?user=${encodeURIComponent(name)}`);

One server handler (Rust):

async fn check(q: Query<Name>) -> Json<bool> {
    Json(db.exists(&q.name).await)
}

Four lines. No schema library. No validation framework. Just a question and an answer.

That is perhaps 5% of forms. The other 95% need nothing the browser did not already provide.

"But server-side validation!" Quite right. Client validation is UX. Server validation is security. You need both. But the client side was already written for you. By the browser team. In 2014.

The Complete Form

For those who appreciate a working example, here is a registration form with full native validation, visual feedback, and cross-field awareness. Zero dependencies:

<form id="signup">
  <label>Email
    <input type="email" required
      placeholder="you@company.com">
  </label>
  <label>Password
    <input type="password" required
      minlength="8" maxlength="128">
  </label>
  <label>Website
    <input type="url"
      placeholder="https://...">
  </label>
  <button type="submit">Sign Up</button>
</form>
/* Validation feedback (no JS) */
input:user-invalid { outline: 2px solid #c00; }
input:user-valid   { outline: 2px solid #0a0; }

/* Disable submit until valid */
form:has(:invalid) [type="submit"] {
  opacity: 0.4;
  pointer-events: none;
}

Load the page: every field pristine, button dimmed. Fill in a valid email: green outline, button still dimmed (password empty). Fill in a password of eight characters: green outline, button activates. Entire interaction model in HTML and CSS. The JavaScript team can have the afternoon off.