# Error Summary

> Collects the errors in a form and takes the user to the field that failed. Frame component.

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.

> **Two routes to the same markup**
>
> When the code that renders the markup can call a builder function, in TypeScript, Kotlin or Java, `lt.errorSummary({ id })` gives the box the class, `role="alert"`, `tabindex="-1"`, and its name from the heading, whose id it makes from the box’s (`errors-title`), already in the HTML. When it cannot, in a Go template, PHP or plain HTML, the template writes the heading’s `id` and the box’s `aria-labelledby`, as it writes `for` and `id` on a field, and the component sets the class, the role and `tabindex`. The component never writes an id: a morph replaces an element whose id the server did not send. Focus moving here, and on to the field, comes from the component in both cases. See [Markup and behaviour](/markup-and-behaviour.md#builder-function-or-web-component).

```ts
// 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()
```

## How to use it

**Plain HTML**

```html
<!-- The stylesheets the page needs. Without a build step it is `<link>`, not
     `import`: a browser does not resolve a package name. -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.0/src/tokens/tokens.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.0/src/components/frame/error-summary/error-summary.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.0/src/components/css/label/label.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.0/src/components/css/input/input.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.0/src/components/css/button/button.css">
<!-- Or everything in one file: https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.0/dist/lotek.css -->
```

```html
<form action="/jobs" method="post" novalidate>
  <!-- The component sets the class, role="alert" and tabindex="-1"; the id and aria-labelledby that name the box are the template's. -->
  <lt-error-summary id="errors" aria-labelledby="errors-title" hidden>
    <h2 id="errors-title"></h2>
    <ul class="lt-list"></ul>
  </lt-error-summary>


  <label class="lt-label" for="fixer">Fixer's address</label>
  <input class="lt-input" id="fixer" type="email" name="fixer" />
  <button class="lt-button">Order the job</button>
</form>


<!-- Once for the whole app, in the page template. Not once per component. -->
<script type="module">
  import { defineLtErrorSummary } from
    "https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.0/dist/components/frame/error-summary/lt-error-summary.js"


  defineLtErrorSummary()
</script>


<script type="module">
  const box = document.getElementById("errors")


  function showErrors(errors) {
    box.querySelector("ul").replaceChildren(
      ...errors.map((e) => {
        const link = document.createElement("a")
        link.href = `#${e.id}`
        link.textContent = e.message
        const item = document.createElement("li")
        item.append(link)
        return item
      }),
    )


    // The heading and hidden are written here, as a server would.
    if (errors.length > 0) {
      box.querySelector("h2").textContent =
        errors.length === 1 ? "The form has one error" : `The form has ${errors.length} errors`
    }
    // Hidden and shown again, so focus also moves when the form fails
    // again while the box is already visible.
    box.hidden = true
    box.hidden = errors.length === 0
  }


  const fixer = document.getElementById("fixer")


  // The script stands in for the server's answer: it stops a submission
  // that fails, and writes what the server would send. One that passes
  // goes to the server.
  document.querySelector("form").addEventListener("submit", (event) => {
    const errors = fixer.value.includes("@")
      ? []
      : [{ id: "fixer", message: "Enter the fixer's address with an @" }]


    if (errors.length > 0) event.preventDefault()
    showErrors(errors)
  })
</script>
```

The component moves focus here as soon as `hidden` is removed. The script only stands in for a server: with one, the server renders the box filled in and without `hidden`, as in [The markup](#the-markup) and the kotlinx.html tab, and the page needs no script beyond the registration. The messages are set with `textContent`, never through `innerHTML`, since they can contain what the user typed.

**React**

```ts
// src/lotek.d.ts, gives <lt-error-summary> types in JSX
import "@lo-tek/interfaces/react-jsx"
```

```tsx
import "@lo-tek/interfaces/tokens.css"
import "@lo-tek/interfaces/error-summary.css"


import { lt } from "@lo-tek/interfaces/react"
import { defineLtErrorSummary } from "@lo-tek/interfaces/error-summary"


defineLtErrorSummary()


type FormError = { id: string; message: string }


export function ErrorSummary({ errors }: { errors: FormError[] }) {
  const box = lt.errorSummary({ count: errors.length, id: "errors" })


  return (
    <lt-error-summary {...box.host}>
      <h2 {...box.title}>
        {errors.length === 1 ? "The form has one error" : `The form has ${errors.length} errors`}
      </h2>
      <ul {...lt.list()}>
        {errors.map((e) => (
          <li key={e.id}><a href={`#${e.id}`}>{e.message}</a></li>
        ))}
      </ul>
    </lt-error-summary>
  )
}
```

**Astro**

```astro
---
import { lt } from "@lo-tek/interfaces"


import "@lo-tek/interfaces/tokens.css"
import "@lo-tek/interfaces/error-summary.css"


const { errors } = Astro.props
const box = lt.errorSummary({ count: errors.length, id: "errors" })
---


<lt-error-summary {...box.host}>
  <h2 {...box.title}>The form has {errors.length} errors</h2>
  <ul {...lt.list()}>
    {errors.map((e) => <li><a href={`#${e.id}`}>{e.message}</a></li>)}
  </ul>
</lt-error-summary>


<script>
  import { defineLtErrorSummary } from "@lo-tek/interfaces/error-summary"
  defineLtErrorSummary()
</script>
```

**Datastar**

```html
<!-- The stylesheets the page needs. Without a build step it is `<link>`, not
     `import`: a browser does not resolve a package name. -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.0/src/tokens/tokens.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.0/src/components/frame/error-summary/error-summary.css">
<!-- Or everything in one file: https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.0/dist/lotek.css -->


<!-- Once for the whole app, in the page template. Not once per component. -->
<script type="module">
  import { defineLtErrorSummary } from
    "https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.0/dist/components/frame/error-summary/lt-error-summary.js"


  defineLtErrorSummary()
</script>


<lt-error-summary id="errors" aria-labelledby="errors-title" hidden>
  <h2 id="errors-title"></h2>
  <ul class="lt-list"></ul>
</lt-error-summary>
```

The server sends the whole box again when the form is validated, with the heading and the list filled in, and without `hidden` when there are errors. The component notices that the box became visible and moves focus there.

If the form fails again while the box is already visible, focus does not move, because the signal is that `hidden` is removed. Send two patches in the same response then: first the box with `hidden`, then the box without. The component sees that it was hidden and shown again, and moves focus. See [The markup](#the-markup).

**kotlinx.html**

On a Kotlin server you call `Lt.errorSummary()`, here with kotlinx.html and `lo-tek-kotlinx-html`. The server validates the submission and renders the form again with the faults: the heading and the links are filled in, and the box is hidden when there are none. There is no script beyond the registration. The component moves focus to the box when the page arrives with it visible.

```kotlin
import kotlinx.html.*
import no.lotek.*
import no.lotek.InputType
import no.lotek.html.lt
import no.lotek.html.ltErrorSummary


data class Fault(val field: String, val message: String)


fun FlowContent.orderForm(fixer: String = "", faults: List<Fault> = emptyList()) {
    val summary = Lt.errorSummary(count = faults.size, id = "errors")
    val field = Lt.field(id = "fixer", invalid = faults.any { it.field == "fixer" })
    form(action = "/jobs", method = FormMethod.post) {
        attributes["novalidate"] = ""
        ltErrorSummary {
            lt(summary.host)
            h2 {
                lt(summary.title)
                if (faults.isNotEmpty()) +(if (faults.size == 1) "The form has one error" else "The form has ${faults.size} errors")
            }
            ul {
                lt(Lt.list())
                for (fault in faults) li { a(href = "#${fault.field}") { +fault.message } }
            }
        }
        label { lt(field.label); +"Fixer's address" }
        input {
            lt(Lt.input(type = InputType.EMAIL), field.control)
            name = "fixer"
            // What the user typed comes back, so a failed submission loses nothing.
            if (fixer.isNotEmpty()) value = fixer
        }
        button { lt(Lt.button()); +"Order the job" }
    }
}
```

The handler for `POST /jobs` validates and answers with the same function, with status 422 when something failed. With Ktor, see [With Ktor](/kotlin.md#with-ktor):

```kotlin
// The faults for what was submitted. Empty when the job can be ordered.
fun faultsIn(fixer: String): List<Fault> =
    if (fixer.contains("@")) emptyList()
    else listOf(Fault("fixer", "Enter the fixer's address with an @"))
```

```kotlin
// Once for the whole app, in the page template. Not once per component.
head { link(rel = "stylesheet", href = LotekWebJar.CSS) }
body {
    orderForm()
    script(type = "module") { unsafe { +"import { defineLt } from \"${LotekWebJar.REGISTER}\"; defineLt()" } }
}
```

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](/markup-and-behaviour.md#ids-that-survive-an-update).

## The markup

```ts
const box = lt.errorSummary({ count: errors.length, id: "errors" })
```

```html
<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](/markup-and-behaviour.md).

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.

## Links point to the field’s id

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.

```html
<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">
```

## Classes you can style

| Class | What it is |
| - | - |
| `.lt-error-summary` | The box itself. Sits on `<lt-error-summary>` |
| `.lt-error-summary__title` | The heading above the list |

| Variable | Fallback |
| - | - |
| `--lt-error-summary-padding` | `var(--lt-spacing-4)` |
| `--lt-error-summary-radius` | `var(--lt-spacing-1)` |

```css
.lt-error-summary {
  --lt-error-summary-padding: var(--lt-spacing-6);
}
```

## Accessibility

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.
