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, shown without its comments; the files you install keep them, with the reasoning behind every rule.
---
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
);
}
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));
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.