DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

Build an accessible accordion from scratch (with a real height animation)

The accordion is one of those components everyone reaches for a library to get, and then spends a week fighting the library. It is worth building once by hand, because the whole thing is about 70 lines of vanilla JS and every line teaches you something you will reuse elsewhere. Here is how I built one for Day 27 of my DesignFromZero series.

Start with the right element

The number one accordion bug is a clickable <div> as the header. A div is not focusable, is not in the tab order, does not fire on Enter or Space, and tells assistive tech nothing. You then bolt all of that back on with tabindex, keydown handlers and a fake role, and you still get it slightly wrong.

Use a real <button>. You get keyboard focus, Enter/Space activation firing a click, and the button role for free. Wrap each button in a heading so the set of headers also forms an outline a screen reader user can jump through:

<h3 class="acc-header">
  <button class="acc-trigger" aria-expanded="false" aria-controls="p0">Billing</button>
</h3>
<div id="p0" role="region" aria-labelledby="t0"></div>
Enter fullscreen mode Exit fullscreen mode

Three attributes carry the accessibility. aria-expanded is a live boolean the reader announces ("Billing, collapsed, button"). aria-controls links the button to the panel it opens. On the panel, role="region" plus aria-labelledby pointing back at the button means the expanded content is announced with the header's own text instead of being an anonymous block.

The animation problem

Now the part everyone gets stuck on: you cannot transition height: auto. The browser has no numeric target to interpolate towards, so the panel just pops open with no animation.

The old workarounds all have a cost. Animating max-height to a magic number bigger than any content eases oddly and clips if you guess too low. Animating a transform: scaleY squashes the text. Measuring scrollHeight and setting an explicit pixel max-height works, but you have to re-measure on resize and font load, and reset to none afterwards so tall content is not clipped.

The clean modern fix is a CSS grid trick. Make the panel a one-row grid and transition its track from 0fr to 1fr:

.acc-panel { display: grid; grid-template-rows: 0fr; transition: grid-template-rows .3s ease; }
.acc-panel[data-open="true"] { grid-template-rows: 1fr; }
.acc-panel-inner { overflow: hidden; }
Enter fullscreen mode Exit fullscreen mode

fr units are animatable in grid, so the row grows smoothly to whatever the content actually is. No magic number, no measuring. The inner wrapper with overflow: hidden clips the text while the row is short. Safari picked this up in 16.4; if you need older engines, the measured max-height fallback is your plan B.

One function, two states

Keep the ARIA state and the visual state impossible to desync by writing both in one place:

function setOpen(i, open){
  triggers[i].setAttribute("aria-expanded", String(open)); // announced
  panels[i].setAttribute("data-open", String(open));       // animates
}
Enter fullscreen mode Exit fullscreen mode

Single-open versus allow-multiple is then a single branch. In allow-multiple, panels are independent. In single-open, opening one closes the others:

function toggle(i){
  const willOpen = !isOpen(i);
  if (mode === "single" && willOpen)
    for (let j = 0; j < n; j++) if (j !== i) setOpen(j, false);
  setOpen(i, willOpen);
}
Enter fullscreen mode Exit fullscreen mode

Both modes should let every panel close, so an all-collapsed state is valid. If you let users switch modes at runtime, just enforce the rule once when they flip to single: keep the first open panel and collapse the rest.

Keyboard and motion

Activation is already done for you by the button, so all the pattern adds is navigation between headers: Arrow Down/Up move focus to the next/previous header (wrap at the ends), Home/End jump to first/last. Call preventDefault() so the arrows do not scroll the page instead. Unlike a tablist, the headers stay in the normal tab order.

Finally, respect people who ask for less motion. Because the whole animation lives in one CSS transition, honouring the preference is two lines:

@media (prefers-reduced-motion: reduce){ .acc-panel { transition: none; } }
Enter fullscreen mode Exit fullscreen mode

Accordion or tabs?

They share the disclosure idea but solve different layouts. An accordion stacks sections vertically and can show none, one, or many at once, so users stay in one scroll flow. Tabs lay sections out horizontally and show exactly one panel, for peer views of equal weight. Scannable, independent chunks like FAQs and filters want an accordion; a few equal "pages" want tabs.

Live demo with a LOOK / UNDERSTAND / BUILD walkthrough: https://dev48v.infy.uk/design/day27-accordion.html

Top comments (0)