Forms: Accessibility and Validation
|
This section documents general HTML5 and CSS concepts — it is not tied to any specific framework or library. This content was generated with the assistance of AI. Verify it against current MDN documentation and browser-support tables (caniuse.com) before relying on it in production, since HTML/CSS features and browser support continue to evolve. |
Forms are one of the few places on a page where a user actively interacts rather than just reads, so
accessibility and validation problems there are especially costly: an unlabeled field or a confusing tab order
can block a screen-reader or keyboard user from signing up, sending feedback, or paying for something
altogether. This page covers making form fields perceivable and operable — correctly associating labels with
inputs, grouping related fields, and supporting keyboard navigation — and then covers validating what a user
typed, both with native HTML5 constraints and with the JavaScript Constraint Validation API. It assumes
familiarity with the form elements themselves (input, select, textarea, button, and their attributes),
documented in Forms and Form Styling. For the broader page-level accessibility context this page doesn’t
cover — WCAG conformance levels, legal accessibility standards, and validation tools — see
Web Accessibility.
Associating labels with inputs
A <label> is only useful to assistive technology if it is explicitly associated with the field it
describes — proximity on the page is not enough. A common mistake is to place text next to an input without
that association:
<p>First name:</p>
<br />
<input type="text" id="first-name" />
Visually this can look identical to a properly labeled field, but a screen reader has no way to connect the
<p> text to the <input>: when a user tabs to the field, the screen reader announces only "text field, blank" — it never reads "First name". The fix is one of two equivalent patterns:
-
An explicit association, using a
<label>with aforattribute whose value matches the input’sid:<label for="first-name">First name</label> <input type="text" id="first-name" /> -
An implicit association, by wrapping the input inside the
<label>itself (nofor/idpair needed):<label>First name: <input type="text" /></label>
Either way, when a screen-reader user navigates to the input, the screen reader reads out "First name" before announcing the field, telling the user what value is expected without relying on any visual layout.
Grouping related fields with fieldset and legend
Longer forms are usually broken into visual groups using whitespace or borders, but that grouping is invisible
to a screen-reader user unless it is also expressed in markup. The <fieldset> element wraps a related set of
fields, and a <legend> nested as its first child provides the group’s caption:
<form>
<fieldset>
<legend>Add user's details:</legend>
<label for="first-name">First name:</label>
<input type="text" id="first-name" />
<label for="last-name">Last name:</label>
<input type="text" id="last-name" />
<label for="email">E-mail:</label>
<input type="email" id="email" />
</fieldset>
<fieldset>
<legend>Set a password:</legend>
<label for="password">Password:</label>
<input type="password" id="password" />
<label for="confirm-password">Confirm password:</label>
<input type="password" id="confirm-password" />
</fieldset>
</form>
A screen reader announces the <legend> text when it enters the <fieldset>, so the second group above might
be read as "Set a password: password, edit text; confirm password, edit text" — giving every field in the
group the surrounding context it needs, without the user having to infer it from layout alone.
Keyboard navigation and focus
Not every user can operate a mouse — doing so requires following a visual pointer, a certain steadiness of touch, and fine motor skills that some users lack or that some input devices don’t support. A form (like the rest of a page) needs to be fully operable from the keyboard alone:
-
Tab moves focus forward through focusable elements (inputs, buttons, links) in document order.
-
Shift+Tab moves focus backward, letting a user retrace their steps.
-
Enter submits a form (when focus is inside it) or activates a focused link or button.
-
Space activates a focused button, and toggles a focused checkbox or radio input.
Testing this is as simple as opening a form and pressing Tab repeatedly without touching the mouse: the focus
should move through the fields in a sensible order (normally the order they appear in the markup) and never
land somewhere invisible or skip a field a user needs to fill in. CSS positioning or a mis-ordered tabindex
can silently break this, so it is worth re-checking after any layout change.
Every focusable element also needs a visible indication of which one currently has focus, so a keyboard user
always knows where they are on the page. Browsers provide a default focus ring, and it can be styled (never
removed outright) with the :focus pseudo-class:
input:focus,
button:focus {
outline: 2px solid #1a73e8;
outline-offset: 2px;
}
The required attribute
The simplest way to catch a missing answer before a form is even submitted is the required boolean
attribute, which the browser enforces natively:
<label for="first-name">First name: <span>*</span></label>
<input type="text" id="first-name" name="firstname" required />
Submitting a form with an empty required field is blocked by the browser itself, which focuses the offending
field and shows a built-in validation message — no JavaScript required. Marking required fields visually (the
<span>*</span> above, alongside a text note such as "* required") also helps sighted users notice which
fields are mandatory before they try to submit.
Native HTML5 constraint validation
Beyond simply requiring a value, the type attribute lets the browser validate the shape of that value.
type="email" is the most common example — it rejects text that isn’t a plausible email address without any
custom code:
<label for="email">Email</label>
<input type="email" id="email" name="email" required />
The browser exposes the outcome of this constraint checking to CSS through the :valid and :invalid
pseudo-classes, which match a field depending on whether its current value satisfies its constraints
(required, type, pattern, min/max, and so on):
:root {
--valid-color: green;
--invalid-color: red;
}
input:valid,
textarea:valid {
border-color: var(--valid-color);
}
input:invalid,
textarea:invalid {
border-color: var(--invalid-color);
}
A field with no value yet is neither obviously valid nor invalid to the user, so many forms only apply the
:invalid styling after the user has interacted with the field (see the worked example below) rather than
painting every empty required field red before the user has had a chance to type anything.
The JavaScript Constraint Validation API
Native required/type validation covers a great deal, but it can’t express custom rules — password
confirmation matching, business-specific formats, or a custom error message wording. The Constraint Validation
API, available on every form control element, extends the same native mechanism from JavaScript instead of
replacing it:
-
element.checkValidity()re-runs the element’s constraints and returnstrue/falsewithout showing any UI (there is alsoform.checkValidity(), which checks every control in the form). -
element.reportValidity()does the same check but also shows the browser’s native validation message if it fails. -
element.setCustomValidity(message)marks the element invalid with a custom message: passing any non-empty string makes the element fail validation (and match:invalid) regardless of its other constraints, while passing an empty string""clears the custom error and lets normal constraint checking resume. -
The
invalidevent fires on a form control when it fails validation, whether that check was triggered by a form submission attempt or by a manualcheckValidity()/reportValidity()call.
const password = document.getElementById("password");
const confirmPassword = document.getElementById("confirm-password");
function validateConfirmPassword() {
if (confirmPassword.value !== password.value) {
confirmPassword.setCustomValidity("Passwords do not match.");
} else {
confirmPassword.setCustomValidity(""); // clears the custom error
}
}
password.addEventListener("input", validateConfirmPassword);
confirmPassword.addEventListener("input", validateConfirmPassword);
Because setCustomValidity feeds into the same constraint-validation mechanism as required and type, the
field above still participates in form.checkValidity(), still blocks submission while invalid, and still
matches :invalid in CSS — the custom rule behaves exactly like a native one from every other part of the
platform.
Worked example: custom message with custom styling
Combining a custom validation message with custom styling needs the invalid event (to react the moment the
browser flags the field) alongside the :invalid pseudo-class (to style it) and a class toggled once the user
has actually interacted with the field, so an empty required field isn’t shown as an error before the user has
had a chance to type into it:
<label for="signup-email">Email: <span>*</span></label>
<input type="email" id="signup-email" name="email" required />
<span class="error-message" id="signup-email-error"></span>
input.touched:invalid {
border-color: red;
}
.error-message {
display: block;
color: red;
font-size: 0.875rem;
min-height: 1.2em;
}
const email = document.getElementById("signup-email");
const errorMessage = document.getElementById("signup-email-error");
email.addEventListener("invalid", (event) => {
event.preventDefault(); // suppress the browser's own validation bubble
email.classList.add("touched");
errorMessage.textContent = email.validity.valueMissing
? "Please enter your email address."
: "Please enter a valid email address.";
});
email.addEventListener("input", () => {
if (email.checkValidity()) {
errorMessage.textContent = "";
}
});
The invalid event fires whenever the browser would otherwise show its own validation bubble (typically on a
failed submission attempt), so event.preventDefault() there replaces that native bubble with the page’s own
.error-message text, while the touched class — added only once the field has actually failed validation — keeps the red border from appearing on a field the user hasn’t reached yet. The input listener clears the
message as soon as the value becomes valid again, giving the user immediate feedback as they fix it.