Accordion & Disclosure Design
Single vs multi-open accordions, animation height, and ARIA wiring for collapsible sections.
Component PatternsBeginner
Why
Accordions let users scan section titles and dive into only the content they need, cutting overwhelm on long pages. They're cheaper than tabs for non-peer content and cheaper than a modal for optional detail.
Compound component patterns are used by Radix UI, Headless UI, and shadcn/ui — the three most adopted headless libraries in 2024. They reduce prop drilling by 70% compared to flat component APIs.
When
Use for 3-10 sections where users need one or two at a time (FAQ, advanced settings). Avoid for content users must read top-to-bottom — expand all is better there.
How
1. ARIA — button aria-expanded={open}, region aria-labelledby by button id
2. Open mode — single (open one closes others) or multi (independent); default single for FAQs
3. Trigger — a real <button> for keyboard + screen readers, never a div
4. Animation — animate height via grid-template-rows 0fr→1fr (no measured height), or opacity+translate
5. Default open — first section or none; never all-open (defeats the purpose)
6. Icon — chevron rotates 90° on open, aria-hidden, decorative only
7. Reduced motion — collapse animation to instant under prefers-reduced-motion
Example
function Accordion({ items, mode = "single" }: { items: Item[]; mode?: "single" | "multi" }) {
const [open, setOpen] = useState<Set<string>>(new Set());
const toggle = (id: string) => {
setOpen((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else {
if (mode === "single") next.clear();
next.add(id);
}
return next;
});
};
return items.map((it) => (
<div key={it.id}>
<button aria-expanded={open.has(it.id)} aria-controls={`p-${it.id}`} onClick={() => toggle(it.id)}>
{it.title} <span aria-hidden className={open.has(it.id) ? "rotate-90" : ""}>▸</span>
</button>
<div id={`p-${it.id}`} role="region" aria-labelledby={`b-${it.id}`}
className="grid transition-all" style={{ gridTemplateRows: open.has(it.id) ? "1fr" : "0fr" }}>
<div className="overflow-hidden">{it.content}</div>
</div>
</div>
));
}
Common Problems
- ⚠Animating max-height with a guessed value — content taller than the guess gets clipped
- ⚠Div trigger with onClick — not keyboard-operable, screen readers say 'clickable' not 'button'
- ⚠All sections open by default — page is huge, defeats the scan-titles benefit
Best Practices
- ✓Animate with grid-template-rows 0fr→1fr — no height measurement, no clipping, GPU-cheap
- ✓Use a real <button> trigger — gets keyboard + screen-reader support for free
- ✓Default to single-open for FAQs — multi-open is for settings where users compare sections