HTML forms · 9 / 9
lesson 9

Accessible error patterns

aria-describedby for inline errors, error summaries, focus management on submit, and live regions for async errors.

~ 14 min read·lesson 9 of 9
0 / 9

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.

inline-error.html
<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.

Tip

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.

check your understanding
You add a red border to invalid fields with 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.

error-summary.html
<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.

focus-first-invalid.js
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.

Watch out

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.

check your understanding
You build a custom JavaScript form. After validation fails, you want the screen reader to announce a list of errors and the user to be able to click each error to jump to the field. The right approach is:

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.

live-region.html
<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.

async-field-error.js
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.

Tip

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.

check your understanding
An async server check tells you the user's username is taken. You add an error to a freshly-created element and inject it into the page. The screen reader does not announce it. Why?
check your understanding
You want a server-validated "Send" button: when clicked, the page should announce "Sending..." then either "Sent" or "Failed: ..." once the request completes. The screen reader should announce each transition without interrupting the user mid-thought. Which markup is right?
check your understanding
A long form has 12 fields. The user submits and 4 fail validation. You want the cleanest accessible response. Which pattern fits best?
← prevfinish course →
KeepLearningcertificate
for completing
HTML forms
0 of 9 read