Accessibility · Forms and Errors
Native Constraint Validation and the ValidityState Object
How the browser evaluates required, pattern, min, and max attributes, what each ValidityState flag means, and where the built-in validation loop breaks down.
Every form control carries a validity state whether or not any script touches it. The browser evaluates a fixed set of constraints declared as attributes (required, pattern, min, max, minlength, maxlength, step, type) and exposes the result through the validity property as a ValidityState object with one boolean flag per constraint plus a few derived ones. The set of flags is fixed by the specification, and each flag answers a single question about the current value.
The state is recomputed lazily. Reading input.validity triggers evaluation of the current value; the value is not re-validated on every keystroke in the background. The checkValidity() and reportValidity() methods evaluate and act, and the invalid event fires on each control that fails when either is called on a form or a fieldset.
The flags and what raises them
valueMissing is true when the control is required and its value is the empty string. For a checkbox this means unchecked, for a radio group it means no radio in the group is checked, and for a file input it means no file was selected. The empty string test is literal: a text input containing a single space is not missing a value, which is why a required field can pass with whitespace and why trimming belongs in a script rather than in the attribute.
typeMismatch is true when the value does not parse as the declared type. type="email" and type="url" are the two types that produce it; a type="number" with non-numeric text does not raise typeMismatch but produces an empty value, because the number input’s value is specified to be the empty string when the user’s text cannot be parsed as a number.
patternMismatch is true when the value does not match the pattern attribute’s regular expression. The expression is compiled with the v flag in current specifications (formerly u), which means it is anchored implicitly at both ends: the pattern must match the entire value, not a substring of it. A pattern of [0-9]{5} accepts exactly five digits and rejects six. The change from u to v also brought stricter parsing of character class syntax, so a pattern containing an unescaped - or [ inside a class can fail to compile and be ignored entirely. When a pattern does not compile, the browser drops it and no patternMismatch is ever raised.
tooLong and tooShort are true when the value’s length in UTF-16 code units falls outside maxlength and minlength. The unit matters: a character outside the Basic Multilingual Plane counts as two units, so a maxlength of 10 accepts at most five such characters. maxlength is also enforced during typing by refusing input beyond the limit, while minlength is only checked on submission, so a field can be edited freely below the minimum and fails only when the form is validated.
rangeUnderflow and rangeOverflow compare the numeric value against min and max, and stepMismatch is true when the value is not an integral multiple of step from the step base. For type="number" the default step is 1 with a base of 0, so a value of 2.5 raises stepMismatch unless step="any" is set. For type="date" the step is in days. badInput is true when the user has typed something the control cannot convert to a value at all: a number input containing “abc” reports badInput rather than a range failure.
Two flags are not about the value. customError is true when setCustomValidity() has been called with a non-empty string, and valid is the derived conjunction: it is true only when every other flag is false.
The evaluation order and the first message
The invalid event fires per failing control, and the browser’s own bubble shows one message at a time. Which constraint produces the message is not specified as a fixed precedence, and browsers differ: a required empty field with a pattern reports the required failure in some engines and the pattern failure in others. The message strings themselves are implementation-defined and localised to the browser’s UI language, not the page’s language.
That last point is the one that most often pushes a team off native validation. A page in one language with a browser in another shows error text in the browser’s language. The strings cannot be styled, cannot be positioned, and are not exposed to the accessibility tree in the way a document-authored message is: they appear as browser chrome attached to the field, not as page content.
Reading and overriding state
The API for interacting with the state is small.
const field = document.querySelector('#code');
field.addEventListener('invalid', () => {
// Runs per failing control when the form is checked.
console.log(field.validity.valueMissing, field.validationMessage);
});
// Setting a custom error suppresses the element's own validity.
field.setCustomValidity('That code has expired.');
console.log(field.validity.customError); // true
setCustomValidity() with a non-empty string makes the control invalid regardless of its attribute constraints. Clearing it requires calling the method with an empty string; setting it to a message and forgetting to clear it leaves the field permanently invalid, which is the single most common bug in code that uses the API. The pattern that works is to clear first, then set only if a problem remains:
function revalidate(field, message) {
field.setCustomValidity(''); // clear before re-evaluating
if (message) field.setCustomValidity(message);
field.reportValidity(); // surface it, if desired
}
novalidate on the form element suppresses the automatic check on submission, letting a script run its own validation and decide what to do. formnovalidate on a submit button does the same for that one button, which is how a “Save draft” action submits without validating while the “Submit” action does not.
Where the built-in loop breaks down
Three structural limits make native validation insufficient as the only layer.
The message is not attached to the page. Browser bubbles are transient, are dismissed on the next interaction, and do not persist for a user who navigates away from the field and returns. WCAG 3.3.1 requires that an error be identified in text, and a bubble that has already vanished identifies nothing. Persisting the error as page content requires the described-by wiring in Wiring Error Messages to Their Fields.
The check happens at submission. Validation runs when the form is submitted or when the API is called, not as the user types. This is the correct default for an unfamiliar form, because early validation punishes a user mid-entry. It is also the reason native validation produces a burst of errors at the moment of submission, with no indication of which field to fix first beyond the browser’s own focus placement, which is on the first invalid control in tree order. The timing question has its own treatment in Inline Validation Timing and Announcements.
Constraints cannot express cross-field rules. required and pattern are per-control. A rule such as “the end date must be after the start date” or “the confirmation must equal the original” has no attribute, and the only representation is setCustomValidity() on one of the two fields, which then reports a message about a relationship as if it were a property of that one field. The error belongs to the pair, and the API has no place to put a pair.
There is a fourth limit that is a matter of degree rather than structure: pattern is anchored, ASCII-oriented in its common usage, and compiled by the browser with an unspecified error recovery. A pattern intended to accept international phone numbers with optional spaces and parentheses is difficult to write correctly and easy to write in a way that rejects valid input. Server-side validation is mandatory regardless, because the client state is trivially bypassable; the native state exists to give feedback, not to enforce.
What the state is good for
The value of the native layer is that it is the only validation machinery that every browser implements identically in structure, that assistive technology can query, and that requires no script to produce a first line of defence. A form with correct required, type, and autocomplete attributes works before any JavaScript loads, and the :invalid and :user-invalid CSS pseudo-classes let styling respond without script.
:user-invalid matters for styling because it applies only after the user has interacted, while :invalid applies to an untouched empty required field from first paint. A form that styles :invalid red at load presents every required field as an error before the user has done anything. Styling on :user-invalid avoids that, at the cost of being newer than :invalid and absent from older engines.
The state is a report about the current value, not a verdict about the user. Treating it as the latter is what produces forms that scold on the first keystroke. Treated as a queryable report, it is the cheapest source of truth about whether a field’s content satisfies its declared constraints, and the rest of the error-handling work builds on top of it.