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.

Accordion sections versus a multi-step wizard Two columns comparing a single form split into collapsible accordion sections with a multi-step wizard, focusing on how validation errors are found. Accordion sections • one form, one submit • any section can be open or closed ✗ errors can hide in closed sections ✓ users can jump between sections freely Multi-step wizard • one step visible at a time • each step validated on Next ✓ errors always on the visible step ✗ harder to jump around and review
Accordions submit everything at once, so errors can sit in closed sections; wizards validate each step before the user can leave it.

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.

Submitting an accordion form with hidden errors On submit, inline errors are rendered, sections containing invalid fields are flagged, the first failing section is opened, a status message lists the flagged sections, and reportValidity focuses the first invalid field. Submit preventDefault Inline errors every invalid field Flag sections "— needs attention" in summary Open first failing details.open = true reportValidity focus visible field
Flag every failing section, open the first, then let reportValidity focus a field that is now visible.

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.

Accordion section states during validation A section can be closed and valid, closed with errors and flagged, open with errors while the user fixes them, or open and valid; submit opens the first flagged section. closed, valid closed, flagged open, fixing open, valid submit finds errors first failing → opened all fields fixed user opens it
Only the first failing section is opened automatically; the others stay flagged until the user opens them.

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.

← Back to Progressive Disclosure Techniques