Lesson 09 / 25
Accordion
The pattern for progressive disclosure, comparing the native disclosure element against a hand-built accordion, tracking the expansion state, and the structural rule audit.
Contents
The tab pattern carried a constraint: only one section is visible at a time. The “Editions,” “Subject headings,” and “Related records” sections at the bottom of the record detail page do not need that constraint. The user might want to keep all three open at once, or might read the page and leave without opening any of them.
The unconstrained solution is the accordion, and it rests on a single state declaration: is the section expanded right now? This lesson specifies that one declaration and shows why it going stale is the most commonly produced defect.
The Problem the Pattern Solves
The accordion keeps secondary content from stretching the page. The measure is secondariness: if most users complete their task without opening a section, that section can be collapsed.
When the measure is not met, the pattern does harm. A record’s shelf position, whether it can be borrowed, and its due date are required information; folding them forces the user to make one more key press and hides the fact that the information exists at all.
Two further limits apply. A closed section’s content is hidden in the document; content the user needs to find by searching the page does not belong inside an accordion. Second, folding two or three short paragraphs gains nothing — the cost of the opening action outweighs the space it saves.
The Native Element First
This pattern has a native counterpart: the details and summary elements. The
summary element is the disclosure control, details is the container, and whether it
is open is read from the open attribute.
What they bring is: the open and close behavior, the control’s focusability, operability
by the Enter and Space keys, and reporting the expansion state to the tree. None of it is
written separately. Giving details elements a shared name also defines that only one
stays open at a time; if this behavior is needed, it is tested by feature detection and
built by hand when unsupported.
The cost sits in two places: controlling the disclosure marker’s appearance from the presentation layer is limited, and applying a transition to the opening motion is not easy. On the catalog’s record detail page, this cost is one worth paying.
The Hand-Built Accordion
If the native element does not meet a requirement, the pattern is built with a button.
<h3> <button aria-expanded="true" aria-controls="panel-editions" id="heading-editions"> Editions </button> </h3> <div id="panel-editions" role="region" aria-labelledby="heading-editions">…</div> <h3> <button aria-expanded="false" aria-controls="panel-subjects" id="heading-subjects"> Subject headings </button> </h3> <div id="panel-subjects" role="region" aria-labelledby="heading-subjects" hidden>…</div>
Three decisions are binding. The button sits inside a heading element; the heading is not written inside the button, the button is written inside the heading. This puts the section name on the outline and lets the user move between sections from the heading list. The heading level is chosen against the surrounding outline; the accordion itself does not impose a level.
The aria-expanded state is written on the button, not the panel: the thing doing the
expanding is the button. The panel’s role="region" declaration and its
aria-labelledby bond let the section be found in a bookmark list; if the section count
is high, that list gets crowded and the declaration may not be worth applying.
The arrow icon used as a disclosure marker is presentational; the state is reported by
aria-expanded, not the icon.
Tracking the Expansion State
// accordion.mjs — tracking expansion state and the structural rule audit const SECTIONS = ["Editions", "Subject headings", "Related records"]; // mode: "multiple" (independent) | "single" (one section at a time) const initial = (mode) => ({ mode, open: new Set() }); function toggle(d, i) { const a = new Set(d.open); if (a.has(i)) a.delete(i); else if (d.mode === "single") { a.clear(); a.add(i); } else a.add(i); return { ...d, open: a }; } // The state the markup produces: aria-expanded on the button, hidden on the panel const generated = (d) => SECTIONS.map((name, i) => ({ name, "aria-expanded": d.open.has(i), panelVisible: d.open.has(i) })); function trace(mode, seq) { let d = initial(mode); console.log(`\n${mode} mode:`); console.log(" triggered".padEnd(22) + "aria-expanded".padEnd(24) + "visible panel"); console.log(" " + "(initial)".padEnd(20) + generated(d).map((o) => (o["aria-expanded"] ? "1" : "0")).join(" ").padEnd(24) + generated(d).filter((o) => o.panelVisible).length); for (const i of seq) { d = toggle(d, i); const g = generated(d); console.log(" " + SECTIONS[i].padEnd(20) + g.map((o) => (o["aria-expanded"] ? "1" : "0")).join(" ").padEnd(24) + g.filter((o) => o.panelVisible).length); } return d; } trace("multiple", [0, 1, 2, 1]); const single = trace("single", [0, 1, 2, 2]); // Invariant: the declared state and the visibility always match; at most one panel in single mode console.log("\ninvariant scan:"); let states = 0, breaks = 0; for (const mode of ["multiple", "single"]) { const seen = new Set(); const queue = [initial(mode)]; const key = (d) => [...d.open].sort().join(","); seen.add(key(queue[0])); while (queue.length) { const d = queue.shift(); states++; const g = generated(d); if (g.some((o) => o["aria-expanded"] !== o.panelVisible)) breaks++; if (mode === "single" && g.filter((o) => o.panelVisible).length > 1) breaks++; for (let i = 0; i < SECTIONS.length; i++) { const y = toggle(d, i); if (seen.has(key(y))) continue; seen.add(key(y)); queue.push(y); } } } console.log(` states scanned: ${states}, invariant breaks: ${breaks}`); console.log(` states reached in single mode: ${SECTIONS.length + 1}`); // --- Structural rule audit ------------------------------------------------- // Each record: the element wrapping the heading button, the aria-controls target, panel hiding const MARKUP = [ { name: "Editions", wrapper: "h3", controls: "panel-editions", expanded: true, panelHidden: false, panels: ["panel-editions"] }, { name: "Subject headings", wrapper: "div", controls: "panel-subjects", expanded: false, panelHidden: true, panels: ["panel-subjects"] }, { name: "Related records", wrapper: "h3", controls: "panel-related-2", expanded: true, panelHidden: true, panels: ["panel-related"] }, ]; const HEADING_ELEMENTS = new Set(["h1", "h2", "h3", "h4", "h5", "h6"]); const EXISTING_IDS = new Set(["panel-editions", "panel-subjects", "panel-related"]); console.log("\nstructural rule audit:"); let findings = 0; for (const k of MARKUP) { const b = []; if (!HEADING_ELEMENTS.has(k.wrapper)) b.push(`heading button is not inside a heading element (${k.wrapper})`); if (!EXISTING_IDS.has(k.controls)) b.push(`aria-controls points to an id that does not exist (${k.controls})`); if (k.expanded === k.panelHidden) b.push(`aria-expanded=${k.expanded} but the panel is ${k.panelHidden ? "hidden" : "visible"}`); for (const x of b) { findings++; console.log(" " + k.name.padEnd(20) + x); } } console.log(`\n${findings} findings across ${MARKUP.length} sections`);
multiple mode: triggered aria-expanded visible panel (initial) 0 0 0 0 Editions 1 0 0 1 Subject headings 1 1 0 2 Related records 1 1 1 3 Subject headings 1 0 1 2 single mode: triggered aria-expanded visible panel (initial) 0 0 0 0 Editions 1 0 0 1 Subject headings 0 1 0 1 Related records 0 0 1 1 Related records 0 0 0 0 invariant scan: states scanned: 12, invariant breaks: 0 states reached in single mode: 4 structural rule audit: Subject headings heading button is not inside a heading element (div) Related records aria-controls points to an id that does not exist (panel-related-2) Related records aria-expanded=true but the panel is hidden 3 findings across 3 sections
Reading the Findings
Comparing the two traces shows the state space difference between the two modes. In multiple mode, three sections reach eight states: each section is independently open or closed. In single mode, the number of states reached is four — three sections, or none. The scan total, twelve, is the sum of the two.
The third line of the single-mode trace shows the pattern’s most commonly overlooked consequence: when the user opens the second section, the first one closes on its own. On screen, this means the page shifts and the place being read disappears. The closing produces no announcement; the user only hears what they opened. This is why single mode is chosen only when the section count is high and the content is long.
The structural audit produces three findings, and none of them is visible on screen.
Missing heading element. The second section’s disclosure button sits inside a div.
On screen there is no difference at all; on the outline, that section does not exist, and
a user navigating from the heading list sees only two of the three sections.
Broken control reference. The third section’s aria-controls value points to an id
that does not exist; the declaration silently falls through.
The declaration and the visibility diverging. The same section is declared expanded but its panel is hidden. This is the pattern’s most expensive defect: the user is told the section is open, finds no content when they open it, and assumes the mistake is their own.
Keyboard Contract
| Key | Behavior |
|---|---|
Tab |
Each heading button is its own tab stop. |
Enter / Space |
Opens or closes the section. |
| Arrow keys | Optional; if built, moves between headings without opening a section. |
This is where the accordion departs from the tab pattern. The tab strip carries a single
selection, so it uses the roving tabindex; the accordion’s headings are independent
buttons, and each holds its own stop. If the roving tabindex were applied here, the user
could never reach the second section with the Tab key.
When a section opens, focus stays on the button. It does not move into the panel: the
user may want to open the next section before reading the one they just opened, and
focus escaping would block that. Entering the panel is done with the Tab key.
Measurable Constraints
4.1.2 Name, Role, Value. The expansion state must be readable programmatically and updated on every change.
2.5.8 Target Size. The whole heading row is the clickable area; an implementation where only the arrow icon is clickable both drops the target below 24 pixels and fails the user’s expectation of clicking the heading text.
1.4.11 Non-text Contrast. The line separating sections carries information and must meet the 3:1 measure.
2.4.6 Headings and Labels. The heading text must describe the section’s content; text like “Detail” or “Other” does not meet this measure.
2.3.3 Animation from Interactions. If a transition is applied to the opening motion, the transition must be removed once reduced motion is signaled; the criterion is AAA level, but in long lists the motion of sections opening one after another produces discomfort.
Common Mistake
Writing the state declaration on the panel. The aria-expanded attribute is written
on the control that does the expanding. Written on the panel, the button is left without
state, and the user cannot tell whether the section is open or closed while focused on
the button. Catching it: checking whether every element with a role of button carries
the declaration.
Building the heading button as a link. Opening a section is an action and does not change an address; the component is a button.
Leaving a closed panel in the tree. A panel hidden only in the presentation layer stays in the tree; the user reads the content of closed sections and it contradicts the state declaration. Catching it is the audit’s third rule.
Starting every section open. Progressive disclosure exists to shorten the page; an accordion that starts fully open just adds an extra layer of key presses. The decision of which section is open on page load is written into the specification.
Summary
- The accordion folds secondary content; required information, content that must be found by searching the page, and very short sections are not folded.
- The pattern has a native counterpart, and it brings the opening behavior, focusability, the key contract, and the state declaration together.
- When hand-built, the button is written inside a heading element rather than the heading inside the button; this puts the section on the outline.
- The
aria-expandeddeclaration is written on the button that expands the panel and must match the visibility in every state. - Every accordion heading is its own tab stop; the roving tabindex belongs only to the tab pattern.
- In single mode, opening one section silently closes another and reduces the state space from eight to four; this behavior is chosen only for long, multi-section lists.
Next Step
In the three patterns so far, opened content stayed inside the page’s flow: the panel took up a place, pushed the content beneath it down, and focus moved on its own. The “My Account” control in the catalog’s top bar behaves differently — the list it opens sits above the page, covers the content beneath it, and closes when the user clicks outside it. This is the first pattern that requires containing focus and returning it to a point when it closes. The next lesson specifies menus and dropdown panels: the distinction between the two and the point at which a focus trap forms.
To keep your progress and take notes, Log in
My notes
Log in to take notes.