---
title: Accordion
source: 'https://academia.sh/en/courses/accessible-patterns/accordion'
course: 'Accessible Component Patterns'
language: en
updated: '2026-08-19T05:19:51+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```html
<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

```js
// 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-expanded` declaration 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.
