Skip to content

Error Summary

When a submission stops, the user should learn why, and reach the field in one click. The server writes the heading and the list. The component does the two things the browser does not do by itself: it moves focus here when the box becomes visible, and it gives focus to the control itself when a link is followed.

// main.ts, runs once when the app starts
import { defineLtErrorSummary } from "@lo-tek/interfaces/error-summary"
import "@lo-tek/interfaces/tokens.css"
import "@lo-tek/interfaces/error-summary.css"
defineLtErrorSummary()

The ids of the box and the heading come from the template. Make them from a key in the data, never from the loop index or a counter, so they are unique on the page and the same on every render: a morph recognises an element by its id. See Ids that survive an update.

const box = lt.errorSummary({ count: errors.length, id: "errors" })
<lt-error-summary class="lt-error-summary" role="alert" tabindex="-1" id="errors" aria-labelledby="errors-title">
<h2 class="lt-error-summary__title" id="errors-title">The form has two errors</h2>
<ul class="lt-list">
<li><a href="#fixer">Enter the fixer's address with an @</a></li>
<li><a href="#deliver-by">Enter the date as day, month and year</a></li>
</ul>
</lt-error-summary>

The server writes the heading and the list. The component used to create the heading itself, and then it disappeared at the first update from the server and never came back. When the code that renders the markup can call a builder function, lt.errorSummary({ id }) or Lt.errorSummary() writes the class, role="alert", tabindex="-1" and the name from the heading; otherwise the component sets the class, the role and tabindex, and puts them back if a patch removes them. If the server has written a different role, it stays. The name needs an id on the heading, which the template writes: when the heading has one and the box no name, the component points aria-labelledby at it, and follows a heading a patch has replaced; when the heading has none, it says so in the console. See Markup and behaviour.

When there are no errors, the builder function sets hidden on the box. An empty summary with the heading “The form has 0 errors” is worse than none.

Each link in the list should point to the id of the control that failed. If it points to a <label>, the component follows the label on to the control, whether the label points with for or wraps the control.

<li><a href="#deliver-by">Enter the date as day, month and year</a></li>
...
<label class="lt-label" for="deliver-by">Deliver by</label>
<input class="lt-input" id="deliver-by" type="date">
ClassWhat it is
.lt-error-summaryThe box itself. Sits on <lt-error-summary>
.lt-error-summary__titleThe heading above the list
VariableFallback
--lt-error-summary-paddingvar(--lt-spacing-4)
--lt-error-summary-radiusvar(--lt-spacing-1)
.lt-error-summary {
--lt-error-summary-padding: var(--lt-spacing-6);
}

What a screen reader is given when the recipe above is used, step by step. It is checked through AT-SPI, the interface Orca reads, in Chromium and Firefox, on every change, by check-accessibility-tree.ts, and every change the check lists as a mutation must make it fail.

  1. Focus #fixer: focus on the textbox "Fixer's address".
  2. Press Enter: focus on the alert "The form has one error".
  3. Press Tab: focus on the link "Enter the fixer's address with an @".
  4. Press Enter: focus on the textbox "Fixer's address".

Where a browser does not do what the specification says, which the check requires until the browser changes:

  • Firefox: Firefox 150 (Playwright firefox-1522), 2026-10-29: role=alert is ROLE_ALERT, where Core-AAM maps it to ROLE_NOTIFICATION.

The box is role="alert", so it is announced when it becomes visible. It has tabindex="-1" so it can receive focus without becoming a tab stop.

Two things are easy to forget, and both are the reason the component exists:

  • Focus moves to the box when it becomes visible. Without that, someone listening to the page does not know the submission stopped. Set data-autofocus="false" if you do not want it. The attribute is not called autofocus, because that is a boolean property on the element, and React 19 sets the property instead of the attribute. Opting out would then not work. The signal is that hidden is removed. If the box is already visible when the form fails again, the app has to toggle hidden on and off, or call focus() itself. The component does not move focus because the list changes, since a summary that shrinks while the user corrects would then pull focus out of the field. One exception: if focus did not land the first time, because the box was in a hidden panel, it tries again at the next change to the list, but only when nobody is in a field. If the box has no tabindex, the component sets it. When the box is hidden, it lets go of focus, also from a link inside it: Firefox and WebKit otherwise left focus on the hidden box, and idiomorph with ignoreActiveValue does not update the content of the element that has focus. For the same reason the box takes focus after an update has finished, not in the middle of it.
  • Focus goes to the control itself when a link is followed. A plain anchor link only gives focus to a target that can take it. If it points to a label or a heading, the browser scrolls the view, but document.activeElement becomes <body>, and the screen reader is not told where the user is. Tab continues from the target regardless; it is the focus and the announcement that are missing. The component follows a label to its control, and gives a target that cannot take focus tabindex="-1" for the jump.

The component also attaches to links the server sends in later, so a summary that is updated while the user corrects keeps working.