Validating Accordion Form Sections
Long forms are often split into collapsible sections — “Personal details”, “Address”, “Payment”, “Preferences” — so users can focus on one part at a time. But collapsing sections creates a validation trap: an invalid field inside a closed <details> cannot be focused, so reportValidity() silently fails to show anything, or the browser opens the section unexpectedly and the user loses their place. Users see a form that refuses to submit for no visible reason. This recipe validates accordion forms built on native <details> elements: it marks every section that contains an error, opens the section containing the first problem before calling reportValidity(), summarises the sections that need attention, and keeps collapsed-but-valid sections out of the way, all using the Constraint Validation API and the site’s novalidate baseline.
When This Pattern Applies
Use it whenever a single form is split into sections that can be hidden while still being submitted together:
- Long applications and profiles, where sections collapse to reduce visual load.
- Checkout pages with collapsible delivery, billing and payment blocks.
- Settings pages grouped into expandable categories and saved with one button.
Multi-step wizards that validate each step before moving on are a different pattern — only one step is visible and each step is validated independently — covered in validating multi-step forms per step. Accordions keep every section in one form and one submission, which is exactly why errors can hide in closed sections. The wider topic is progressive disclosure techniques.
Minimal Working Accordion Validation
<form id="profile" novalidate>
<div id="section-status" class="section-status" role="status"></div>
<details class="section" id="sec-personal" open>
<summary>Personal details <span class="section-flag" hidden>— needs attention</span></summary>
<!-- fields -->
</details>
<details class="section" id="sec-address">
<summary>Address <span class="section-flag" hidden>— needs attention</span></summary>
<!-- fields -->
</details>
<details class="section" id="sec-payment">
<summary>Payment <span class="section-flag" hidden>— needs attention</span></summary>
<!-- fields -->
</details>
<button type="submit">Save profile</button>
</form>
const form = document.querySelector<HTMLFormElement>("#profile")!;
const sections = [...form.querySelectorAll<HTMLDetailsElement>("details.section")];
const status = form.querySelector<HTMLElement>("#section-status")!;
function sectionsWithErrors(): HTMLDetailsElement[] {
return sections.filter((s) =>
[...s.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>("input, select, textarea")]
.some((el) => el.willValidate && !el.validity.valid),
);
}
function flagSections(failing: HTMLDetailsElement[]): void {
for (const s of sections) {
const bad = failing.includes(s);
s.querySelector<HTMLElement>(".section-flag")!.hidden = !bad;
s.toggleAttribute("data-has-error", bad);
}
}
form.addEventListener("submit", (event) => {
event.preventDefault();
renderInlineErrors(); // messages at every invalid field
const failing = sectionsWithErrors();
flagSections(failing);
if (failing.length === 0) return form.submit();
// Open the FIRST failing section before reportValidity, so the browser can focus inside it.
failing[0].open = true;
const names = failing.map((s) => s.querySelector("summary")!.firstChild!.textContent!.trim());
status.textContent = failing.length === 1
? `${names[0]} needs attention.`
: `${failing.length} sections need attention: ${names.join(", ")}.`;
requestAnimationFrame(() => form.reportValidity()); // focus lands on a now-visible field
});
// Clear a section's flag as soon as all of its fields become valid.
form.addEventListener("input", (e) => {
const section = (e.target as HTMLElement).closest<HTMLDetailsElement>("details.section");
if (section?.hasAttribute("data-has-error") && !sectionsWithErrors().includes(section)) flagSections(sectionsWithErrors());
});
Opening the first failing section before reportValidity() is the key step. Chromium and Firefox will often open a closed <details> themselves to reveal an invalid field (the element supports “find-in-page”-style auto-expansion), but behaviour differs across engines and custom accordions built from divs never do it. Opening it explicitly makes the result identical everywhere and puts the user exactly where the first fix is needed, while the flags on the other sections tell them what remains.
Accordion Validation Option Reference
| Element | Purpose | Notes |
|---|---|---|
<details> / <summary> |
Native disclosure | Keyboard and screen reader support built in |
| Summary flag text | “— needs attention” | Text, not only an icon, so it is announced |
data-has-error |
Styling hook | Or details:has(:user-invalid) in CSS |
details.open = true |
Reveal the first failing section | Before reportValidity() |
| Status message | Lists all failing sections | Polite role="status" |
| Hidden but valid sections | Stay collapsed | Do not force every section open |
| Disabled sections | fieldset disabled inside |
Excluded from validation when not applicable |
The summary flag can also be pure CSS with details:has(:user-invalid) > summary::after, as in styling validation states with the :has() selector. The text span used here has the advantage of being part of the summary’s accessible name, so screen reader users hear “Address — needs attention, collapsed” when they reach it.
Verification Steps
import { test, expect } from "@playwright/test";
test("closed section with an error opens on submit", async ({ page }) => {
await page.goto("/profile");
await page.locator("#sec-address > summary").click(); // make sure it is closed
await page.getByRole("button", { name: "Save profile" }).click();
await expect(page.locator("#sec-address")).toHaveAttribute("open", "");
await expect(page.locator("#sec-address .section-flag")).toBeVisible();
await expect(page.locator("#sec-address input:invalid").first()).toBeFocused();
});
Edge Cases and Failure Modes
Custom accordions that use display: none. Controls inside display: none containers are still validated but cannot be focused, so reportValidity() cannot show them. Open the container first, as this recipe does; there is no way for the browser to focus a hidden field.
Opening every failing section at once. It can push the first error far down the page and overwhelm users. Open the first, flag the rest, and let users open them in turn.
Sections that should not be validated. An optional section the user has not started (“Add a second address”) should have its fields disabled or removed, not merely collapsed; collapsed required fields still block submission.
Section flags that never clear. If the flag is only recomputed on submit, a user who fixes the section still sees “needs attention” until they submit again. Recompute on input, as the implementation does, so the flag disappears the moment the section is valid.
Auto-closing accordions. Designs where opening one section closes the others (exclusive accordions using the name attribute on <details>) can close the section the user is fixing when they open another to read something. Prefer non-exclusive sections in forms, or reopen the failing section on each submit.
Validating a Section When It Closes
Accordions give a natural checkpoint that single long forms lack: the moment a user closes a section, they are telling you they think they are done with it. Validating that section on close — without blocking the close — is a gentle way to surface problems before the final submit. Listen for the toggle event, and when a section closes, check its fields with checkValidity() (which does not move focus) and flag the section if anything fails. Do not reopen it or move focus; the user chose to move on, and a flag in the summary is enough for them to come back when ready. This matches the “reward early, punish late” timing from best practices for inline validation timing, applied at the section level: nothing is reported until the user signals they have finished with a part of the form.
for (const section of sections) {
section.addEventListener("toggle", () => {
if (section.open) return;
const fields = [...section.querySelectorAll<HTMLInputElement>("input, select, textarea")];
const touched = fields.some((f) => f.value !== f.defaultValue);
if (!touched) return; // untouched sections stay neutral
const bad = fields.some((f) => f.willValidate && !f.checkValidity());
section.querySelector<HTMLElement>(".section-flag")!.hidden = !bad;
section.toggleAttribute("data-has-error", bad);
});
}
The touched check keeps sections the user merely peeked into from being flagged, which would otherwise mark every required section as failing before the user has started it.
Pairing With an Error Summary
For long accordion forms, the section flags and the status message act as a summary at the section level. If fields in several sections fail, a full error summary at the top of the form with links to each field is still valuable — and its link handler must open the target field’s section before focusing it, or the link will appear to do nothing.
function focusField(id: string): void {
const field = document.getElementById(id)!;
field.closest<HTMLDetailsElement>("details.section")?.setAttribute("open", "");
requestAnimationFrame(() => {
field.scrollIntoView({ block: "center" });
field.focus({ preventScroll: true });
});
}
That one line — open the ancestor section — is the difference between a summary that works in an accordion form and one that silently fails. The summary component itself is built in building an accessible error summary.
Frequently Asked Questions
Why doesn't reportValidity show my error inside a collapsed section?
A field in a hidden container cannot be focused, so the browser cannot show it. Open the section containing the first invalid field before calling reportValidity.
Should all sections with errors open on submit?
Usually only the first. Flag the others in their summary text so users know which sections need attention, and let them open those in turn.
How do I show that a collapsed section has an error?
Add text such as "— needs attention" to the section's summary, so it is visible and announced, and optionally style it with details:has(:user-invalid).
Do fields in collapsed sections still get submitted?
Yes. Fields inside a closed details element are still part of the form. Disable or remove fields in sections that do not apply, or they will be validated and submitted.
Related Guides
- Progressive Disclosure Techniques — revealing fields without breaking validation.
- Validating Multi-Step Forms Per Step — the wizard alternative.
- Styling Validation States with the :has() Selector — CSS section flags.
- Building an Accessible Error Summary — linking into sections.