Back to Machine Coding
Machine Coding
easy
junior

How would you build an accessible accordion component?

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

6 min read·~25 min to think through

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.

tsx
<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.
ts
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.

tsx
<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:

  1. grid-template-rows: 0fr → 1fr (modern, ~95% browser support):
css
.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.

  1. Measure with JS: requestAnimationFrame(() => panel.style.height = panel.scrollHeight + "px"). Robust but JS-heavy.
  1. max-height trick: 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) or defaultOpen="overview" (single).
  • Controlled mode: optional open + onOpenChange props.
  • 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 → 0 directly — doesn't work.
  • Forgetting aria-expanded — screen reader users don't know it's a disclosure.
  • display: none panel 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

tsx
function AccordionItem({ id, title, children, isOpen, toggle }: any) {
  return (
    <div className="border-b">
      <h3>
        <button
          aria-expanded={isOpen}
          aria-controls={`p-${id}`}
          onClick={() => toggle(id)}
          className="w-full text-left py-3"
        >
          {title}
          <ChevronDown className={`transition-transform ${isOpen ? "rotate-180" : ""}`} />
        </button>
      </h3>
      <div
        id={`p-${id}`}
        role="region"
        className="grid transition-[grid-template-rows] duration-200"
        style={{ gridTemplateRows: isOpen ? "1fr" : "0fr" }}
      >
        <div className="overflow-hidden">{children}</div>
      </div>
    </div>
  );
}
Modern grid-row height animation

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.

Senior engineer discussion

Senior signal: real <button>, aria-expanded + aria-controls, grid-template-rows animation trick, and single vs multi mode design.

Related questions