The Validation Problem
Most forms ship with a validation library attached. React Hook Form for field state management, Yup or Zod for schema definition, error message components for display. Each layer adds dependencies, bundle weight, and abstractions you need to learn. For a large class of forms, none of it is necessary.
The browser ships a complete constraint validation system. It has been available since HTML5 and carries around 97% global coverage according to Can I Use. It covers required fields, type checking, pattern matching, length limits, and custom error messages. This is the third post in a series on native browser primitives — the previous two covered <dialog> for modals and the Popover API for menus and tooltips. Form validation follows the same pattern: the platform already did the work.
The Built-in Attributes
HTML validation starts with attributes. No JavaScript required:
<input type="email" required>
<input type="url" required>
<input type="number" min="1" max="100">
<input type="text" minlength="2" maxlength="50" required>
<input type="text" pattern="[A-Za-z]{3,}" required>
<input type="password" minlength="8" required>
The type attribute does more than hint the input widget. type="email" validates that the value matches email syntax. type="url" enforces URL format. type="number" with min and max validates numeric range. pattern accepts a regular expression. required marks the field as mandatory. The browser checks all of these on form submission and focuses the first failing field automatically.
The ValidityState Object
Every form element exposes a validity property containing a ValidityState object. This is the diagnostic interface for constraint failures:
const input = document.querySelector('#email');
input.validity.valid // true if all constraints pass
input.validity.valueMissing // true if required and empty
input.validity.typeMismatch // true if value doesn't match the type (e.g. invalid email)
input.validity.patternMismatch // true if value fails the pattern attribute
input.validity.tooShort // true if value is under minlength
input.validity.tooLong // true if value exceeds maxlength
input.validity.rangeUnderflow // true if value is below min
input.validity.rangeOverflow // true if value is above max
input.validity.customError // true if setCustomValidity() was called with a message
You do not need to re-implement these checks in JavaScript. Read the state the browser already computed.
Custom Error Messages with setCustomValidity
The browser's default error messages are generic and vary by browser. setCustomValidity() replaces them with your own copy:
const email = document.querySelector('#email');
email.addEventListener('input', () => {
if (email.validity.typeMismatch) {
email.setCustomValidity('Enter a valid email address, like name@example.com.');
} else {
email.setCustomValidity(''); // Clear the custom message when valid
}
});
Passing an empty string clears the custom message and marks the field as valid. Passing any non-empty string marks the field invalid and sets the message that appears in the browser's validation bubble. The field also becomes flagged in validity.customError.
For server-side validation responses, setCustomValidity() is the right tool. After receiving a "username already taken" response from the API, set the message on the username field directly, then call reportValidity() to surface it:
usernameInput.setCustomValidity('This username is already taken.');
usernameInput.reportValidity();
checkValidity and reportValidity
Two methods for different scenarios:
-
checkValidity()validates the field or form and returnstrueorfalse. It fires aninvalidevent on each failing field. No validation UI is shown. -
reportValidity()validates the field or form, shows the browser's validation UI (the error bubble, focused on the first invalid field), and returnstrueorfalse.
Both work on individual elements and on the <form> element itself. form.checkValidity() checks every field and returns false if any fail. Use checkValidity() when you want to handle error display yourself; use reportValidity() when the browser's default UI is sufficient.
novalidate and Inline Errors
The novalidate attribute on a form disables the browser's automatic validation on submit. Use it when you want to control validation timing, show inline errors rather than browser bubbles, or style error states with your own design system:
<form id="signup-form" novalidate>
<label for="email">Email</label>
<input type="email" id="email" name="email" required>
<span class="error" aria-live="polite"></span>
<label for="password">Password</label>
<input type="password" id="password" name="password" minlength="8" required>
<span class="error" aria-live="polite"></span>
<button type="submit">Create account</button>
</form>
<script>
const form = document.getElementById('signup-form');
form.addEventListener('submit', (e) => {
e.preventDefault();
// Clear previous errors
form.querySelectorAll('.error').forEach(el => el.textContent = '');
if (!form.checkValidity()) {
[...form.elements].forEach(field => {
if (!field.validity.valid) {
const error = field.nextElementSibling;
if (error?.classList.contains('error')) {
error.textContent = field.validationMessage;
}
}
});
form.querySelector(':invalid')?.focus();
return;
}
// Form is valid — proceed
const data = Object.fromEntries(new FormData(form));
console.log(data);
});
</script>
The validationMessage property returns the browser's localised error string for the current validity state, including any message set by setCustomValidity(). Reading it gives you localised messages without maintaining your own string table.
The aria-live="polite" on the error spans tells assistive technology to announce the message when it appears. This is the minimal requirement for accessible inline validation.
The CSS Side: :user-invalid
Two pseudo-classes style form fields based on validity:
-
:invalidapplies as soon as the field fails a constraint, including before the user has typed anything. An empty required field is immediately:invalidon page load. This produces red borders around every field before the user has done anything wrong. -
:user-invalidapplies only after the user has interacted with the field and left it in an invalid state, or after a failed submit attempt. This is the behaviour you want.
:user-invalid reached Baseline Newly Available in October 2023, supported in Chrome, Edge, Firefox, and Safari. For browsers that don't support it, the selector is ignored, which is a safe degradation.
/ Avoid this — shows errors before the user types /
input:invalid {
border-color: red;
}
/ Use this instead — errors appear after interaction /
input:user-invalid {
border-color: #dc2626;
outline: 2px solid #dc2626;
outline-offset: 1px;
}
input:user-valid {
border-color: #16a34a;
}
requestSubmit() vs submit()
When submitting a form programmatically from JavaScript, most developers reach for form.submit(). This is the wrong method. form.submit() bypasses HTML5 validation entirely and does not fire the submit event, which means your submit event handler is never called.
form.requestSubmit() is the correct method. It behaves as if the user clicked a submit button: the form is validated first, the submit event fires, and e.preventDefault() works. If validation fails, submission is cancelled and the first invalid field receives focus automatically.
// Wrong — skips validation, skips submit event
form.submit();
// Correct — validates first, fires submit event, preventDefault works
form.requestSubmit();
All modern browsers support requestSubmit(). Internet Explorer ended extended support in 2022 and is not a target for new development.
FormData: Free Field Collection
Once a form passes validation, FormData reads all its fields without manual value collection. It handles every input type including checkboxes, radio groups, selects, and file inputs:
form.addEventListener('submit', (e) => {
e.preventDefault();
if (!form.checkValidity()) return;
const payload = Object.fromEntries(new FormData(form));
fetch('/api/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
});
No document.querySelector for each field, no manual object assembly, no missed fields when the form grows.
A Complete Copy-Paste Form
Here is a signup form combining all of the above: native constraints, inline errors, :user-invalid styling, and FormData collection:
<form id="signup" novalidate>
<div class="field">
<label for="name">Full name</label>
<input type="text" id="name" name="name"
required minlength="2" autocomplete="name">
<span class="error" aria-live="polite"></span>
</div>
<div class="field">
<label for="email">Email address</label>
<input type="email" id="email" name="email"
required autocomplete="email">
<span class="error" aria-live="polite"></span>
</div>
<div class="field">
<label for="password">Password</label>
<input type="password" id="password" name="password"
required minlength="8" autocomplete="new-password">
<span class="error" aria-live="polite"></span>
</div>
<button type="submit">Create account</button>
</form>
<style>
.field {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 1rem;
}
input:user-invalid {
border-color: #dc2626;
outline: 2px solid #dc2626;
outline-offset: 1px;
}
input:user-valid {
border-color: #16a34a;
}
.error {
color: #dc2626;
font-size: 0.875rem;
min-height: 1.25rem;
}
</style>
<script>
const form = document.getElementById('signup');
form.addEventListener('submit', (e) => {
e.preventDefault();
form.querySelectorAll('.error').forEach(el => el.textContent = '');
if (!form.checkValidity()) {
[...form.elements].forEach(field => {
if (!field.validity?.valid && field.nextElementSibling?.classList.contains('error')) {
field.nextElementSibling.textContent = field.validationMessage;
}
});
form.querySelector(':invalid')?.focus();
return;
}
const payload = Object.fromEntries(new FormData(form));
console.log('Submitting:', payload);
});
</script>
What You No Longer Have to Write
Before the Constraint Validation API was production-ready, a validated form commonly required: a schema definition in Yup or Zod, a resolver connecting the schema to a form library, per-field error state management, a touched or dirty tracking system to avoid showing errors before interaction, manual onChange handlers to trigger re-validation, and a submit handler that re-ran schema validation before sending data.
React Hook Form is the most popular React form library, with over 38,000 GitHub stars. For forms without React, or forms with straightforward validation rules, the native API covers the same ground with zero dependencies. When you do need React Hook Form for complex form state, it supports native HTML validation constraints alongside its resolver system. The two are not mutually exclusive.
Start Here
The Constraint Validation API has been in every major browser for over a decade. The :user-invalid pseudo-class closed the last significant CSS gap in October 2023. requestSubmit() handles programmatic submission correctly in all modern browsers.
MDN's Constraint Validation guide covers every attribute and API method in full detail. Copy the demo form above, add the fields your form needs, and ship it.
This is the third post in the native web series. The platform handles the modal (<dialog>), the menu (Popover API), and now the form. The browser has been shipping the tools you have been reaching for libraries to get.