Pattern: inline validation summary
Who uses it: anyone filling a form with more than a
couple of required fields — worth it the moment a reader could miss
which field failed. What "done" looks like: every
problem named in one place AND on its field, with nothing to hunt for.
On submit, if the form is invalid: list every problem field up top, move focus to
that summary first (not straight into a field — a screen-reader user hears the
overview before jumping in), and let each link focus its exact field. Composed
entirely from existing components — the summary box is just .bo-alert
bo-alert--danger, and every field's error state (red border, red label,
required asterisk) comes free from the existing :user-invalid styling
already in .bo-form-field. Try it: submit the empty form below.
Not for per-row saves — in an
editable grid each row saves
on its own (PATCH per line, 422 → that row re-rendered with
cell errors), so a failure comes back on the row the user just acted on and
a summary would only restate it. The summary earns its place on a
document-level submit — posting the whole form — where several
fields can fail at once, including some scrolled out of view.
The last two fields sit inside a closed
<details> and a collapsed card on purpose: each still blocks
submit, so the summary lists it, and following that entry opens the container
before focusing the field. Collapsing a required field does not excuse it.
Markup
<form data-validation-summary novalidate>
<div class="bo-alert bo-alert--danger" data-validation-summary-box hidden role="alert">
<p>There is a problem</p>
<ul></ul> <!-- populated on an invalid submit -->
</div>
<div class="bo-form-field">
<label class="bo-form-field__label" for="vendor">Vendor</label>
<input class="bo-input" id="vendor" required />
</div>
...
</form>
<script type="module">
import { initValidationSummary } from "@busy-office/ui/js";
initValidationSummary(); // once — wires the [data-validation-summary] form
</script> novalidate is required, not optional.
Without it the browser's own interactive validation stops an invalid submit
before the submit event fires, so this behavior never
runs at all — measured: the summary stays hidden and lists nothing. If the
invalid field also sits inside a hidden container the browser cannot focus
it either, so the form refuses to submit with no visible reason and only a
console error.
Field labels come from each field's own <label
for> text — no extra markup needed to name a field in the summary list.
A field hidden inside a tab, a
<details> or a collapsed card still blocks submit, so
the summary lists it — and following its link opens whatever is hiding it before
moving focus, activating the tab so aria-selected stays correct.
That reveal is exported as reveal(el) for any other in-page link
that has to land on a field the user cannot currently see.
If your app boosts forms with htmx's
hx-boost (as this docs site does — found live while building
this demo): add hx-boost="false" to any
[data-validation-summary] form. Without it, htmx's own submit
interception races this behavior's — htmx fetches and swaps the page before (or
instead of) the summary rendering, so the failed-validation state never sticks.
Anatomy
- Summary alert — at the top of the form, listing every error as a link to its field.
- Field-level messages — the same text beside the control, wired with
aria-describedby. - Focus move — on submit failure focus goes to the summary, so screen-reader users hear the count first.
- Live region — the summary is
role="alert", so a dynamic failure announces without a page change.
Data contract
| Request | Returns / on failure |
|---|---|
| POST /form (invalid) | 422 with a field→message map; re-render the SAME form with values preserved and both channels populated |
| POST /form (valid) | redirect or the success fragment — never leave a stale summary on screen |
| client-side | native constraint validation runs first; the server contract is identical either way |
Two swap targets, one endpoint
A validating endpoint answers in two places: the 422
re-renders the form where the user is looking, while the
success usually has to update something else — a
timeline, a row, a list behind the dialog. Point
hx-target at the form, and let the success response
carry the other update out of band:
<!-- The button posts and swaps the FORM area -->
<button hx-post="/po/88213/reject" hx-target="#dialog-body" hx-swap="innerHTML">Reject</button>
<!-- 422: the form comes back, values kept, error wired to the field -->
<div class="bo-alert bo-alert--danger" role="alert" id="err">A reason is required.</div>
<div class="bo-richtext__content" contenteditable="true"
aria-invalid="true" aria-describedby="err">…</div>
<!-- 200: the dialog body gets a confirmation, and the timeline OUTSIDE
the dialog updates out of band in the same response -->
<p>Rejected — reason recorded.</p>
<ol id="timeline-88213" hx-swap-oob="outerHTML" class="bo-timeline">…</ol> Verified by dogfooding the reference app (2026-08-17): without the out-of-band swap the success response replaced the dialog body with a timeline and the real timeline never changed — the action appeared to do nothing. A 422 reaches the user with no app code either — htmx 4 swaps non-2xx responses by default (see using with htmx).
Document-level messages — the one thing the summary is not for
Some messages are about no field at all. "Posting period closed", "this vendor is on hold", "the month-end run is in progress" — there is nothing for the user to retype, so listing them in a field summary sends them hunting for an input that does not exist. Those get a strip at the top of the document instead:
Posting period 2026-07 is closed. This purchase order can be edited and saved, but not posted until the period reopens or a controller reverses the close.
That is an ordinary alert placed in the document header — no new component, no new class. A strip earns its place by being about the document; the moment a message names a field, it belongs on that field and in the summary above.
The test, and why it stays this way
- Can the user fix it by changing a value on this screen? Then it is a field error — attach it to the field, list it in the summary. "Vendor is blocked" is a field error even though only the server could know it.
- Is it a condition of the document or the system? Then it is a strip: it explains why an action is unavailable, and the user's next step is elsewhere (a different period, a different approver, waiting).
- Say what is still possible. A strip that only says "closed" invites a support call; one that says "you can still save, just not post" does not.
- One strip, not a feed. If two document-level conditions hold, the one that blocks the user's likely next action goes first — and if you find yourself needing scroll, paging or filters in here, the screen is doing too much. That is the signal to split the screen, not to build a message centre.
Deliberately not a message centre. A collected inbox of everything the server wanted to say is what a screen grows when errors sit far from where the user is looking. Field-first keeps them where the eyes already are; a document strip is the narrow exception for messages that have no field to sit on. The framework ships no message-manager component, and that is a boundary rather than a gap.
States
| State | What ships |
|---|---|
| No errors | the summary is absent from the DOM, not hidden — an empty alert region confuses assistive tech |
| One error | still use the summary; the consistent path beats a special case |
| Many errors | list all of them, each linking to its field; do not truncate |
| Server-only error | e.g. "vendor is blocked" — the summary is the ONLY place it can appear |
| Fixed as you go | remove entries as fields become valid, but keep the summary until resubmit — the count is the user’s progress bar |
| Cross-field error | attach it to the field the user should change, not to a phantom "form" field |
| No JS | the form carries novalidate so JS can own validation — without initValidationSummary() that also disables the browser's own native constraint-validation UI, so submitting the empty form is not stopped client-side at all: it POSTs straight through with empty field values, and only the server's 422 (see Data contract) catches it |
Components used
complexity 2 of 4