Accessible error patterns
aria-describedby for inline errors, error summaries, focus management on submit, and live regions for async errors.
Form validation is a UX problem when nobody can tell which field failed. A red border helps a sighted user; a screen-reader user gets nothing if all you did was change the colour. This lesson is the accessibility layer of the previous validation lessons — how to surface errors so every audience knows what went wrong and where.
aria-describedby for inline errors
The simplest pattern: each field has an error element underneath it, and aria-describedby connects the two. Screen readers announce the error along with the field's label.
<label for="email">Email</label> <input id="email" name="email" type="email" required aria-describedby="email-error"> <p id="email-error" role="alert">Please enter a valid email address.</p>
The input points at the error element with aria-describedby="email-error". When the user lands on the input, the screen reader announces "Email, edit text, please enter a valid email address". The error is part of the field's description.
role="alert" on the error element does an extra job: when the error appears (or its text changes), the screen reader announces it immediately. Without role="alert", the user only hears the error when they re-focus the field.
When the field becomes valid, hide or empty the error element. With role="alert", an empty element doesn't announce anything; with the element gone entirely, the field's aria-describedby is left dangling but harmless.
Keep the error message short and actionable. "Please enter a valid email address" beats "Validation failed". The user has to hear or read it; help them act on it.
A common mistake: showing the error only with CSS. A field that gets a red border but no text label says "something is wrong" without saying what. Always pair the visual cue with text.
input:user-invalid { border-color: red }. A screen-reader user fills the form. What do they experience?Error summaries at the top
For long forms, an error summary at the top of the form is a powerful pattern. After a failed submit, render a list of every error with links that focus the matching field.
<div id="errors" role="alert" tabindex="-1"> <h2>There were 3 problems with your submission</h2> <ul> <li><a href="#email">Email is not valid</a></li> <li><a href="#age">Age must be 13 or higher</a></li> <li><a href="#tos">You must accept the terms</a></li> </ul> </div> <form>...</form>
After submit, populate this element and focus it. The user (sighted or not) lands on a clear list of what failed. Clicking each link jumps to the field for correction.
tabindex="-1" makes the element focusable from JavaScript (element.focus()) without putting it in the tab order. role="alert" makes screen readers announce the new content as it arrives.
This pattern scales. A 10-field form with 3 errors is much easier to fix from a summary than by tabbing through every field looking for red borders.
Focusing the first error
The browser focuses the first invalid field for you when validation runs natively (on submit, with required attributes). For custom validation logic, you have to do it yourself.
form.addEventListener('submit', (event) => {
event.preventDefault();
if (!form.checkValidity()) {
// Find the first invalid control and focus it.
const firstInvalid = form.querySelector(':invalid, [aria-invalid="true"]');
if (firstInvalid) firstInvalid.focus();
return;
}
// Valid — submit via fetch, etc.
});The CSS pseudo :invalid works in a query selector. So does [aria-invalid="true"] — useful if your code marks fields invalid via that attribute (helpful for async errors that the browser does not know about).
When using an error summary, focus the summary instead of the field — the user reads the list, then chooses which field to jump to.
Don't steal focus during typing. If the user is mid-typing in field A and your async validator just marked field B invalid, do not yank focus to field B. Surface the error visibly and via role="alert"; let the user decide when to handle it.
Live regions for async errors
Inline aria-describedby works when the error is known at the moment the user submits. Async errors — "username already taken" after a server check, "your card was declined" after a payment attempt — arrive later. For those, a live region is the right tool.
A live region is any element with aria-live="polite" (or assertive). When its content changes, a screen reader announces the new content automatically.
<div id="status" aria-live="polite" role="status"></div>
<script>
async function submitForm() {
const status = document.getElementById('status');
status.textContent = "Submitting...";
const result = await sendIt();
if (result.error) {
status.textContent = "Submission failed: " + result.error;
} else {
status.textContent = "Submitted!";
}
}
</script>The screen reader announces each new message as it appears. polite waits for the user to finish whatever they are reading; assertive interrupts. Use polite by default — assertive is for genuinely urgent things.
role="status" is shorthand for "this is a polite live region for status updates"; role="alert" is shorthand for an assertive live region for errors. Either the role or the aria-live attribute works; using both is redundant but harmless.
usernameInput.addEventListener('blur', async () => {
const taken = await isUsernameTaken(usernameInput.value);
if (taken) {
usernameInput.setCustomValidity("Username taken");
usernameInput.setAttribute('aria-invalid', 'true');
usernameError.textContent = "That username is taken — try another.";
} else {
usernameInput.setCustomValidity("");
usernameInput.setAttribute('aria-invalid', 'false');
usernameError.textContent = "";
}
});Three things happening in concert: the Constraint Validation API (lesson 5) blocks form submission, aria-invalid flags the field for selectors and screen-reader announcement, and the per-field error element (with role="alert" if you set it) speaks the error.
Live regions need to exist before the content arrives. If you create a new <div role="alert"> at the moment the error happens, the screen reader may not announce it. Render the live region empty on page load and only update its textContent; the announcement fires reliably.