Build an accessible Accordion / Expandable List
Each item is a header (button) + a panel. Use real <button> + aria-expanded + aria-controls. Support single-open or multi-open mode. Animate height with grid-template-rows: 0fr → 1fr (modern, no JS measure).
An accordion is a list of collapsible disclosure sections. The interview test: did you use a real <button> (so keyboard works for free), wire aria-expanded, animate height correctly, and decide single vs multi-open mode?
Component shape.
<Accordion mode="single"> // or "multi"
<AccordionItem id="overview">
<AccordionHeader>Overview</AccordionHeader>
<AccordionPanel>...</AccordionPanel>
</AccordionItem>
<AccordionItem id="reviews">
<AccordionHeader>Reviews</AccordionHeader>
<AccordionPanel>...</AccordionPanel>
</AccordionItem>
</Accordion>Open-state model.
- Single:
open: string | null. Click toggles or replaces. - Multi:
open: Set<string>. Click toggles independently.
function useAccordion(mode: "single" | "multi") {
const [open, setOpen] = useState<Set<string>>(new Set());
const isOpen = (id: string) => open.has(id);
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 { isOpen, toggle };
}Markup — semantic + accessible.
<div>
{items.map(item => (
<div key={item.id}>
<h3>
<button
aria-expanded={isOpen(item.id)}
aria-controls={`panel-${item.id}`}
id={`header-${item.id}`}
onClick={() => toggle(item.id)}
>
{item.title}
</button>
</h3>
<div
id={`panel-${item.id}`}
role="region"
aria-labelledby={`header-${item.id}`}
hidden={!isOpen(item.id)}
>
{item.body}
</div>
</div>
))}
</div>The <button> inside an <h3> is the WAI-ARIA accordion pattern. The heading level depends on context (h2/h3/h4). The hidden attribute removes the panel from the a11y tree when collapsed.
Animating height — the modern trick. Animating height from auto is famously hard because the browser can't transition to/from auto. Three approaches:
grid-template-rows: 0fr → 1fr(modern, ~95% browser support):
.panel-wrap {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 200ms ease;
}
.panel-wrap[data-open="true"] {
grid-template-rows: 1fr;
}
.panel-wrap > .panel-inner {
overflow: hidden; /* clip during animation */
}No JS measurements — the browser handles it. Cleanest solution today.
- Measure with JS:
requestAnimationFrame(() => panel.style.height = panel.scrollHeight + "px"). Robust but JS-heavy.
max-heighttrick:max-height: 1000px— works only when you know an upper bound; animation feels off because it's a transition between max values.
Keyboard model. WAI-ARIA accordion expects:
- Tab moves focus through the headers (since they're
<button>, this works automatically). - Enter / Space on a focused header toggles.
- Arrow keys to move between headers are optional per the pattern (different from Tabs, where arrow keys are required).
Common patterns to support.
- Default open: pass
defaultOpen={["overview"]}(multi) ordefaultOpen="overview"(single). - Controlled mode: optional
open+onOpenChangeprops. - Disabled item: button disabled, panel stays closed.
- Lazy panel content:
{isOpen && <Heavy/>}— saves work when panels have charts/data fetches. Trade-off: state inside panel resets on collapse.
Common mistakes.
- Using
<div onClick>for the header — no keyboard support, no focus ring, no role. - Animating
height: auto → 0directly — doesn't work. - Forgetting
aria-expanded— screen reader users don't know it's a disclosure. display: nonepanel when expanded but with CSS transition — no animation possible (display is non-animatable).
When to use <details> / <summary> instead. Native disclosure widget — keyboard, ARIA, animation (with open attribute) come for free. Use it when single-section disclosure suffices and you don't need fine UI control. Limitations: harder to animate, harder to coordinate single-mode across multiple, browser styles are inconsistent.
Code
Follow-up questions
- •Why does grid-template-rows enable smooth height animation?
- •When would you use <details>/<summary> instead?
- •How do you preserve panel state across collapse/expand?
- •Why is aria-expanded essential?
Common mistakes
- •<div onClick> instead of <button> — kills keyboard a11y.
- •Animating height: auto directly — no animation occurs.
- •Forgetting aria-controls / id pairing.
- •Using display:none with CSS transition expecting animation.
Performance considerations
- •Lazy-mount panel children if expensive (charts, large lists).
- •Avoid re-rendering all items on toggle — memoize AccordionItem.
Edge cases
- •Single mode + clicking the open one — toggle closed (per WAI-ARIA), don't no-op.
- •Long panel inside a scroll container — opening should not auto-scroll the whole page.
- •Animation interrupted mid-transition — grid-row approach handles smoothly.
Real-world examples
- •FAQ pages, settings panels, mobile nav drawers, Stripe Connect onboarding steps.