Part V β The Craft Β· Lesson 10.8
Accordion
In one lineA list of disclosure toggles β trivial to render, and a complete accessibility contract to get right.
Where you've seen itAny FAQ page, and every settings panel with collapsible sections.
Diagram
TWO MODES β decide before you write state
SINGLE (radio-like) MULTIPLE (checkbox-like)
openId: 'q2' openIds: Set{'q1','q3'}
ββ q1 ββββββββββββββ βΌ ββ q1 ββββββββββββββ β²
ββ q2 ββββββββββββββ€ β² β answer text β
β answer text β ββ q2 ββββββββββββββ€ βΌ
ββ q3 ββββββββββββββ€ βΌ ββ q3 ββββββββββββββ€ β²
ββββββββββββββββββββ β answer text β
ββββββββββββββββββββ
opening q3 closes q2 each toggles independentlyTHE ARIA WIRING β four attributes, all required
<h3>
<button id="hdr-2"
aria-expanded="true" β state, updated on toggle
aria-controls="panel-2"> β points at the panel
What is a service worker?
</button>
</h3>
<div id="panel-2"
role="region"
aria-labelledby="hdr-2" β names the panel
hidden={!open}> β hidden, not just CSS
β¦
</div>
Heading level matters: it puts the accordion in the document
outline so screen-reader users can navigate by heading (8.3).KEYBOARD CONTRACT
Tab move between headers (each is a tab stop)
Enter / Space toggle the focused section
Home / End first / last header (optional, expected in
Arrow ββ previous / next header full ARIA pattern)
Focus NEVER moves into the panel on open β the user chooses (8.4).How it works
- Model state by mode: a single
openIdor aSetof open ids. Deriving one from the other later is messy β decide upfront. - Use a real
<button>inside a heading. The heading level places it in the document outline; the button gives you keys and focus for free. - Wire
aria-expandedandaria-controls, and hide closed panels with thehiddenattribute so they leave the accessibility tree. - Animate height carefully β
height: autoisn't animatable. Usegrid-template-rows: 0fr β 1fr, or measure and animatemax-height. - Consider
<details>/<summary>for simple cases: the platform gives you the semantics and keyboard behaviour with no JavaScript.
const [open, setOpen] = useState(() => new Set());
const toggle = (id) => setOpen((prev) => {
const next = single ? new Set() : new Set(prev);
prev.has(id) ? next.delete(id) : next.add(id);
return next;
});Trade-offs
| Choice | Consequence |
|---|---|
| Single open | Focused reading; more clicking |
| Multiple open | Comparison across sections |
<details> | Free semantics; harder to animate and style |
| Custom | Full control; you own the whole contract |
| Unmount closed panels | Less DOM; loses internal state |
Interview angle
"Build an accordion."
- Ask single or multiple first, because it decides the state shape.
- Buttons inside headings,
aria-expandedandaria-controlswired,hiddenon closed panels, focus staying on the trigger. - If the content is heavy, lazy-render panels on first open and keep them mounted afterwards to preserve scroll and form state.
Recap
- Mode decides the state shape β ask before coding.
- Button-in-heading plus
aria-expanded/aria-controlsis the whole contract. <details>is free and correct when you don't need custom animation.