The exact problem: a form renders a list of errors above the fields after a failed submit, and a keyboard or screen-reader user never learns it exists — because nothing moved focus, nothing was announced, and the list is not reachable from where they are.

Context and Prerequisites

This is the implementation of the summary described in error summary and messaging, and it assumes the branch rule from focus management after validation: one error focuses that field, two or more render and focus the summary.

Core Pattern

interface SummaryEntry { fieldId: string; label: string; action: string; }

export function renderSummary(entries: SummaryEntry[], root: HTMLElement): HTMLElement | null {
  root.replaceChildren();
  if (entries.length < 2) return null;      // one problem goes straight to its field

  const box = document.createElement('div');
  box.className = 'error-summary';
  // Programmatically focusable, but NOT a tab stop: -1 keeps it out of the
  // sequence while letting focus() land on it.
  box.tabIndex = -1;
  box.id = 'error-summary';

  const h = document.createElement('h2');
  // The count goes in the heading because the heading is what a screen reader
  // announces first when focus lands — scope before detail.
  h.textContent = `There is a problem with ${entries.length} answers`;

  const list = document.createElement('ul');
  for (const e of entries) {
    const li = document.createElement('li');
    const a = document.createElement('a');
    a.href = `#${e.fieldId}`;
    a.textContent = `${e.label}${e.action}`;
    a.addEventListener('click', (ev) => {
      ev.preventDefault();
      const field = document.getElementById(e.fieldId);
      if (!field) return;
      // Reveal first: a collapsed section or an unreached step must open before
      // focus can land, or focus() silently no-ops on a hidden element.
      revealAncestors(field);
      (field as HTMLElement).focus();
    });
    li.appendChild(a);
    list.appendChild(li);
  }

  box.append(h, list);
  root.appendChild(box);
  // Focus AFTER insertion, in the same task: focusing a detached node does nothing.
  box.focus();
  return box;
}

Two lines carry most of the accessibility. tabIndex = -1 makes the container focusable without adding a tab stop — a summary in the tab sequence is an extra stop every reader passes through on every pass. And box.focus() after insertion is what announces it: the focus move causes the heading and the list to be read, which is why the container must not also be a live region.

Six steps from a failed submit to the reader editing the right field The submit handler counts three errors and renders the summary into the document. Focus is then moved to the summary container, which is programmatically focusable but not a tab stop. The focus move causes the screen reader to announce the heading, which contains the count, followed by the list. The reader activates an entry. Any collapsed section or unreached step containing the target field is revealed first, because focus cannot land on a hidden element. Finally focus moves to the field itself, which carries aria-invalid and its own message. 1 · render 3 errors, so a summary is built 2 · focus it after insertion, same task 3 · announced heading, count, then the list 4 · reader picks one entry, with Enter or a click 5 · reveal the target open the section, or go to the step 6 · focus the field aria-invalid, message, caret ready Step 5 is the one usually missing: focus() on a hidden element does nothing and returns nothing, so the entry looks broken.

Step-by-Step Walkthrough

  1. Build entries in document order. The list should read in the same direction the form does.

  2. Skip the summary for a single error. Focus that field instead; a list of one is a detour.

  3. Put the count in the heading. It is the first thing announced when focus lands, and it is the answer to the reader’s first question.

  4. Use real links. An <a href="#id"> is keyboard-operable, announced as a link, and works without JavaScript if the ids match.

  5. Reveal before focusing. Open collapsed sections and navigate to the owning wizard step first, or focus() no-ops silently.

  6. Re-render on each attempt. Replace the container’s contents and re-focus, and change the heading text so the second announcement is distinguishable from the first.

Before the edge cases, the markup decisions worth defending in review:

Why each element is what it is The container is a div with tabindex minus one rather than a section with a role, because it needs to be focusable but not a landmark competing with the form. The heading is a real h2 so it appears in the heading outline a screen reader user navigates by. The list is a real ul so the reader is told how many items there are before hearing any of them. Each entry is a real anchor with an href so it is keyboard-operable and announced as a link without any ARIA at all. Element Why not something else div with tabindex="-1" focusable, but not a landmark competing with the form a real h2 appears in the heading outline readers navigate by a real ul announces the item count before the items a real a with href keyboard-operable and announced as a link, with no ARIA Every one of these is the boring choice, and every one removes a line of ARIA that could have been wrong.

Failure Modes and Edge Cases

1. Focusing a node that is not in the document yet

focus() on a detached element does nothing. Insert, then focus, in the same task — not in a setTimeout, which lets a render cycle move focus somewhere else first.

2. The second submit announces nothing

Re-focusing an element that already has focus does not re-announce in several screen readers. Changing the heading text between attempts gives them something new to read.

3. The link jumps under a sticky header

href="#id" scrolls the target flush to the viewport edge. scroll-margin-top on the field fixes it in one declaration, without any scroll arithmetic.

4. An entry for a field that is not rendered

A server error naming a field the form does not show still belongs in the summary — with no link, and with wording that says what the reader can do instead. Omitting it makes the count wrong and the failure invisible.

5. The summary is styled but not semantic

A <div> of <div>s with click handlers is not a list of links. Screen readers announce “list, 3 items” for a real list, which is part of the scope information the summary exists to give.

And the four ways a summary manages to be announced twice:

Four sources of a duplicate announcement A container that is both a live region and a focus target is announced by the region and again by the move. A summary rendered while the field messages are also being written into their own live region produces two utterances of the same sentence. Re-rendering the summary while it has focus can re-announce it in some screen readers. And a visually hidden duplicate of the summary, added for a screen reader, is announced alongside the visible one. Each has the same fix: exactly one thing announces each message. Cause Fix the container is also a live region remove the region; keep the focus move field messages also write a live region clear the field region when the summary appears re-rendering while focused update text in place rather than replacing the node a hidden duplicate "for screen readers" delete it — the visible one is already announced The rule underneath all four: exactly one thing announces each message, and a focus move counts as announcing.

Verification Checklist

Common Pitfalls

  • Rendering the summary but never focusing it. Sighted readers see it appear; keyboard and screen-reader users get no indication it exists, and continue from wherever they were. The focus move is what makes it a summary rather than a decoration.
  • Focusing the heading instead of the container. Focus on an h2 announces the heading and stops. Focus on the container announces the heading and then the list, which is the scope information the reader wanted.
  • Building entries from Object.keys(errors). Field names are not labels. billingAddress.postCode in a summary is a string the reader has never seen and cannot map to anything on screen.
  • Leaving the container in the DOM when empty. An always-present container that is empty most of the time is still a focus target and can still be reached by a stray focus call. Remove it, or render it only when there is something to say.

Related

Error Summary and Messaging

Frequently Asked Questions

Why tabindex minus one rather than zero?

Because minus one makes the container focusable programmatically without inserting it into the tab sequence. With zero, every reader tabbing through the page stops on the summary container on every pass, including before any error exists if the container is always present. Minus one gives you the focus target you need and costs nobody a keystroke.

Should the summary be re-focused on every failed submit?

Yes, and the heading text should change too. Re-focusing an element that already has focus does not re-announce in several screen readers, so a reader who fixes one problem and introduces another can be left with no feedback at all. Rewriting the heading — even just the count — gives the reader something new to hear.

What about an error that has no field to link to?

Include it, without a link, and word it so the reader knows what to do — ‘Your session expired. Sign in again to continue.’ Leaving it out makes the count disagree with the list and hides the actual reason the submit failed, which is the failure mode the summary exists to prevent.