Building an Accessible Error Summary

When a form fails validation with several problems, how does a user learn how many there are, what they are, and how to get to each one? Inline messages beside each field answer “what is wrong here?” but not “what is wrong overall?”. An error summary — a box at the top of the form that says “There are 3 problems” and links to each field — answers the overall question, and it is the pattern public-sector design systems standardised because it works for screen reader users, keyboard users, people with cognitive disabilities and everyone on a long form. This recipe builds one from the same validity data the Constraint Validation API exposes: collected in document order during checkValidity(), rendered with the same messages as the inline errors, focused on submit, linked so each item moves focus to its field, and rendered server-side as well so it works without JavaScript.

When to Use an Error Summary

Use a summary on any form where more than one error can occur at once, which is most forms with more than three fields. It matters most for:

  • Long forms, where the first error may be off-screen and later ones far below it.
  • Server-rendered error pages, where no script moves focus after a reload.
  • Screen reader users, who otherwise discover errors one field at a time.
  • Forms with group-level errors (a “choose at least one” checkbox set) that have no single input to focus.

For a single error, going straight to the field is often better; the trade-off is discussed in error summary vs inline errors. A summary always complements inline messages — never replaces them — because the user needs the message again when they arrive at the field.

Error summary above a form A form with an error summary at the top stating that there are two problems, each a link to the affected field, and the matching inline messages at the fields. Apply for a permit There are 2 problems — Enter your date of birth · Choose a permit type 1 Full name Ada Lovelace Date of birth 2 ✗ Enter your date of birth ✗ Choose a permit type Continue 1 role="alert" region with a heading; receives focus on submit 2 Each summary item is a link to the field's id, with the same text as the inline message 3 Group errors link to the first option of the group
The summary states the count, lists each problem with the same text as its inline message, and links to the field; focus moves to the summary on submit.

Minimal Working Error Summary

<form id="permit" novalidate>
  <div class="error-summary" id="error-summary" tabindex="-1" role="alert" aria-labelledby="error-summary-title" hidden>
    <h2 id="error-summary-title">There is a problem</h2>
    <ul class="error-summary-list"></ul>
  </div>
  <!-- fields, each with an id and an #id-err message element -->
</form>
interface Problem { id: string; message: string }

const form = document.querySelector<HTMLFormElement>("#permit")!;
const summary = form.querySelector<HTMLElement>("#error-summary")!;
const title = summary.querySelector<HTMLElement>("#error-summary-title")!;
const list = summary.querySelector<HTMLUListElement>(".error-summary-list")!;
const baseTitle = document.title;

function collectProblems(): Problem[] {
  const seen = new Set<string>();
  const problems: Problem[] = [];
  for (const el of form.elements) {
    if (!(el instanceof HTMLInputElement || el instanceof HTMLSelectElement || el instanceof HTMLTextAreaElement)) continue;
    if (el.validity.valid || !el.willValidate) continue;
    const key = el.type === "radio" || el.type === "checkbox" ? el.name : el.id;   // one entry per group
    if (seen.has(key)) continue;
    seen.add(key);
    problems.push({ id: el.id, message: messageFor(el) });                         // same text as inline
  }
  return problems;
}

function renderSummary(problems: Problem[]): void {
  if (problems.length === 0) {
    summary.hidden = true;
    document.title = baseTitle;
    return;
  }
  title.textContent = problems.length === 1 ? "There is a problem" : `There are ${problems.length} problems`;
  list.replaceChildren(...problems.map((p) => {
    const li = document.createElement("li");
    const a = Object.assign(document.createElement("a"), { href: `#${p.id}`, textContent: p.message });
    a.addEventListener("click", (e) => {
      e.preventDefault();
      const field = document.getElementById(p.id)!;
      const label = document.querySelector<HTMLElement>(`label[for="${p.id}"]`) ?? field.closest("fieldset")?.querySelector("legend");
      (label ?? field).scrollIntoView({ block: "center" });          // show the label, not just the input
      field.focus({ preventScroll: true });
    });
    li.append(a);
    return li;
  }));
  summary.hidden = false;
  document.title = `Error: ${baseTitle}`;
}

form.addEventListener("submit", (event) => {
  event.preventDefault();
  renderInlineErrors();                                  // your existing per-field messages
  const problems = collectProblems();                    // checkValidity-equivalent, in document order
  renderSummary(problems);
  if (problems.length) {
    summary.focus();                                     // user hears "There are 2 problems" first
    return;
  }
  form.submit();
});

Three details distinguish a summary that helps from one that merely exists. The message text is identical to the inline message at each field, so users recognise the problem when they arrive. The link handler scrolls the label into view, not just the input — landing with the label off the top of the screen leaves the user unsure which field they are in. And radio and checkbox groups produce one entry, not one per option.

Error summary on submit Submitting runs validation, collects one problem per field or group in document order, renders the summary with a count and links, updates the page title, and moves focus to the summary. Submit preventDefault Inline messages per field, from validity Collect problems document order, groups once Render summary count heading + links Focus summary title "Error: …"
The summary is built from the same validity data as the inline messages, so the two can never disagree.

Error Summary Option Reference

Element Attribute / behaviour Purpose
Container tabindex="-1", role="alert" Focusable; announced when revealed
Heading “There is a problem” / “There are n problems” States the scope first
Items links href="#field-id" Keyboard and screen reader navigation
Link click scroll label into view, focus field Lands the user with context
Message text identical to the inline message Recognition at the field
Groups one item per name No duplicate entries for radios
Page title prefix "Error: " First thing heard after a server-rendered reload
Position first element inside the form Found without searching

Using both role="alert" and focus deserves a note: when focus moves to the summary, screen readers read it as the focused element; role="alert" additionally ensures it is announced in the server-rendered case where the page loads with the summary already present and a small script focuses it. Some teams use one or the other; test your combination with the screen readers your users rely on.

Verification Steps

import { test, expect } from "@playwright/test";

test("summary links move focus to the field with matching text", async ({ page }) => {
  await page.goto("/permit");
  await page.getByRole("button", { name: "Continue" }).click();
  const summary = page.locator("#error-summary");
  await expect(summary).toBeFocused();
  await expect(summary.getByRole("heading")).toHaveText("There are 2 problems");
  await summary.getByRole("link", { name: "Enter your date of birth" }).click();
  await expect(page.getByLabel("Date of birth")).toBeFocused();
  await expect(page.locator("#dob-err")).toHaveText("Enter your date of birth");
});

Edge Cases and Failure Modes

Summary without inline messages. A summary that lists errors while the fields show nothing forces users to remember the message while navigating. Always show both.

Messages that differ. “Date of birth is required” in the summary and “Enter your date of birth” at the field makes users wonder whether there are two problems. Generate both from one function.

Links that jump to the input, hiding the label. Browsers scroll an anchor target to the top edge, which often hides its label. Handle the click and scroll the label into view, as above.

Errors that are not about a field. “Your session expired” or “This slot was just booked” have no field to link to. List them as plain text items at the top of the summary, or show them in a separate form-level alert above it.

Keeping the Summary Up to Date

A summary that shows stale problems is worse than none. Decide when it updates and apply the rule consistently. The common approach: rebuild it on every submit attempt, and leave it unchanged while the user edits fields in between — each field’s inline message clears live, but the summary stays as a record of what the last submit found until the user submits again. A livelier alternative removes items as their fields become valid, updating the count; if you do that, do not announce each change, and hide the summary when the last item disappears. Either way, never add new items between submits, which would make the summary grow while the user is fixing things.

Rendering the Summary on the Server

The summary is most valuable exactly when there is no script to move focus: after a server-rendered error response. Render the same markup on the server from the validation result, with the container already visible, the title prefixed with “Error:”, and a tiny inline script — or the autofocus attribute on the summary where appropriate — to focus it after load. Because the markup is identical, the scripted and server paths look and behave the same, which is the goal of progressive enhancement without JavaScript.

export function renderErrorSummary(problems: Array<{ id: string; message: string }>): string {
  if (!problems.length) return "";
  const heading = problems.length === 1 ? "There is a problem" : `There are ${problems.length} problems`;
  const items = problems.map((p) => `<li><a href="#${p.id}">${escapeHtml(p.message)}</a></li>`).join("");
  return `<div class="error-summary" id="error-summary" tabindex="-1" role="alert" aria-labelledby="error-summary-title">
    <h2 id="error-summary-title">${heading}</h2><ul class="error-summary-list">${items}</ul></div>`;
}
Server-rendered summary after a failed post The browser posts the form without JavaScript, the server validates, renders the form with the error summary and inline messages, and the new page's title and summary announce the problem. Browser Server Screen reader POST /permit (2 problems) 422 page: title "Error: …", summary, inline messages page load: "Error: Apply for a permit" alert: "There are 2 problems…"
Without script, the title and the summary's alert role do the announcing that focus management does on the scripted path.

Frequently Asked Questions

What is a form error summary?

A box at the top of a form that states how many problems there are and lists each one as a link to the affected field. It complements the inline messages beside each field.

Should focus move to the error summary or the first invalid field?

With several errors, moving focus to the summary lets users hear the scope first. With a single error, moving straight to the field is usually faster. Choose one rule and apply it consistently.

Should the summary use the same wording as inline errors?

Yes. Identical text lets users recognise the problem when they reach the field. Generate both from the same message function.

How do I include radio groups in the summary?

Add one entry per group, keyed by the radios' shared name, and link it to the first radio in the group, whose label is the group's first option.

← Back to Focus Management & Keyboard Navigation