Form Error Summary
Free · MITHeadless, accessible, and styled entirely through the attribute surface below — bring your own CSS system, or start from the reference theme.
Install
Without a flag you get no styling at all — plain HTML carrying the attribute surface below. --theme copies the same theme in two dialects;--bridge lumos re-points either one onto Lumos for Astro's tokens (it needs a theme to re-point, and Lumos itself, already in your project).
Unstyled — behavior only, you write the CSS
npx astrocraft-ui add forms/form-error-summaryPlain-CSS theme — no build step
npx astrocraft-ui add forms/form-error-summary --theme cssTailwind theme — needs Tailwind v4
npx astrocraft-ui add forms/form-error-summary --theme tailwindPlain-CSS theme, re-pointed onto Lumos tokens
npx astrocraft-ui add forms/form-error-summary --theme css --bridge lumosTailwind theme, re-pointed onto Lumos tokens
npx astrocraft-ui add forms/form-error-summary --theme tailwind --bridge lumosLive demo
Submitting: error summary, autosize, character count
Submit it empty: the summary appears, takes focus, and links to the first field that failed. Fill it in and the button goes aria-busy exactly once. The bio grows as you type and the counter only speaks up near the limit.
Attribute surface
With no class-merging machinery, these attributes are the whole extension point. State is a data-attribute or a native attribute; visibility is the hiddenattribute. Generated from source by pnpm slots.
| Component | Slots | Variants | Runtime state | Native state |
|---|---|---|---|---|
| `FormErrorSummary.astro` | `form-error-summary` `form-error-summary-list` `form-error-summary-title` | — | — | `[hidden]` |
Source
What the command copies — 2 files, alias-free so the copy lands standing alone. This is the whole component; there is nothing else.
---
// src/components/ui/forms/form-error-summary/FormErrorSummary.astro — headless primitive (see ../../README.md).
// The accessible failed-submit pattern, and a WCAG 3.3.1 requirement: when a submit fails, say what
// failed, in one place, and put the keyboard somewhere useful. On submit the script collects every
// control the browser's own constraint validation rejects, lists each as a link to its field, shows
// the summary, and focuses it.
//
// It sets `form.noValidate` once it is running, on purpose: the UA's error bubbles appear one at a
// time, disappear on their own, are announced by almost nothing, and cannot be styled. Replacing
// them is only safe from JavaScript — which is exactly where we are — so with the script absent the
// form keeps native validation and this element stays hidden and harmless.
//
// The list is built at runtime and carries no `data-slot` of its own; style it through its container
// (`[data-slot="form-error-summary-list"] a`). `role="alert"` announces it the moment it appears,
// and `tabindex="-1"` makes it focusable without adding a tab stop.
//
// Put it at the TOP of the form. `for` names the form by id when it sits outside one; `heading` is
// the summary title, kept off the native `title` attribute so that stays a plain passthrough.
import "../../../../styles/structure.css";
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"div"> & { for?: string; heading?: string };
const { for: target, heading = "There is a problem", class: className, ...rest } = Astro.props;
---
<div
class={className}
role="alert"
tabindex="-1"
data-slot="form-error-summary"
data-for={target}
hidden
{...rest}
>
<p data-slot="form-error-summary-title">{heading}</p>
<ul data-slot="form-error-summary-list"></ul>
</div>
<script>
import { onReadyOnce } from "../../_once";
type Validatable = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
let uid = 0;
function isValidatable(el: Element): el is Validatable {
return (
(el instanceof HTMLInputElement ||
el instanceof HTMLSelectElement ||
el instanceof HTMLTextAreaElement) &&
el.willValidate
);
}
/**
* The control's own label text — `labels` is the native association, so it needs no id bookkeeping.
* `aria-hidden` descendants are dropped first, because that is what they mean: FormLabel's required
* asterisk is hidden from the accessibility tree, and without this the summary reads "Full name*".
*/
function nameOf(el: Validatable): string {
const label = el.labels?.[0]?.cloneNode(true);
if (label instanceof HTMLElement) {
for (const decoration of label.querySelectorAll('[aria-hidden="true"]')) decoration.remove();
const text = label.textContent?.trim();
if (text) return text;
}
return el.getAttribute("aria-label") || el.name || "This field";
}
function wire(root: HTMLElement) {
const form = root.dataset.for
? document.getElementById(root.dataset.for)
: root.closest("form");
const list = root.querySelector('[data-slot="form-error-summary-list"]');
if (!(form instanceof HTMLFormElement) || !list) return;
form.noValidate = true;
form.addEventListener("submit", (event) => {
const controls = [...form.elements].filter(isValidatable);
const invalid: Validatable[] = [];
const seen = new Set<string>();
for (const control of controls) {
const ok = control.checkValidity();
control.setAttribute("aria-invalid", String(!ok));
// One entry per radio group: every button in it fails together and they share a message.
if (ok || (control.name && seen.has(control.name))) continue;
if (control.name) seen.add(control.name);
invalid.push(control);
}
if (invalid.length === 0) {
root.hidden = true;
return;
}
event.preventDefault();
list.replaceChildren(
...invalid.map((control) => {
control.id ||= `form-error-field-${(uid += 1)}`;
const link = document.createElement("a");
link.href = `#${control.id}`;
link.textContent = `${nameOf(control)}: ${control.validationMessage}`;
const item = document.createElement("li");
item.append(link);
return item;
}),
);
root.hidden = false;
root.focus();
});
}
onReadyOnce('[data-slot="form-error-summary"]', wire);
</script>
import FormErrorSummary from "./FormErrorSummary.astro";
export { FormErrorSummary };
export default FormErrorSummary;
What you get
The component source, copied into your project by npx astrocraft-ui add forms/form-error-summary — no package dependency, no CSS to fight. Whatever the paid blocks compose, this is it.