React Accordion: Two of Five WAI-ARIA Requirements, on a Page Scoring 100
Our own FAQ accordion had two of five WAI-ARIA requirements, on a page that already scored Lighthouse accessibility 100. The audit and the fix that shipped with it.
A React accordion needs five things beyond useState and a click handler: a heading wrapper around the trigger, an id/aria-controls pair linking button to panel, aria-labelledby pointing back, and — the part almost every tutorial skips — role="region" applied carefully, because slapping it on every panel in a long accordion turns a screen reader's landmark list into noise. Our own /pricing FAQ had two of five. Here is the audit, and the fix that shipped with this post.
The accordion in question is src/components/molecules/FaqItem.tsx, rendered eight times by src/components/organisms/Faq.tsx for the FAQ section on /pricing. It looked done: it opened, it closed, the chevron rotated, and it read fine to a sighted mouse user. It had aria-expanded on the button and nothing else the pattern asks for.
What we had, and what the pattern actually requires
| Requirement | Why it exists | Was it there? |
|---|---|---|
aria-expanded on the trigger | Announces open/closed state | ✅ |
Trigger wrapped in a heading (<h3> or similar) | Lets a screen reader user jump the FAQ list by heading, not just by tab order | ❌ |
id on the trigger, aria-controls pointing to the panel | Wires the button to the region it governs | ❌ |
Matching id on the panel, aria-labelledby pointing back | Names the panel after its question when it's reached directly | ❌ |
role="region" on the panel — applied selectively | Exposes the panel as a landmark, but only while it means something | ❌ |
Three of five, and — same lesson as this codebase's DashboardTabs audit — the two present felt like the whole job. aria-expanded is the one attribute every accordion tutorial mentions, because it's also the one a click handler naturally needs for the chevron's rotation class. The wiring that makes the relationship legible to assistive tech was never required by anything that would have made the component visibly broken.
Why nothing caught it
This is the sharper version of the same story our tabs post told about /dashboard, because this time the excuse doesn't apply. /dashboard is ƒ — server-rendered on demand behind a login — so it was structurally outside the eight pages LIGHTHOUSE.md audited to accessibility 100. /pricing is not. It's ○, fully static, and it's one of the eight pages that pass. This defect was sitting on a page that already scored 100 on an automated accessibility audit.
The reason is the same reason aria-controls slipped past on the tabs: it's an authoring-practices recommendation, not a required ARIA attribute. axe-core (what Lighthouse runs under the hood) checks that present ARIA attributes have valid values and that roles nest correctly — aria-expanded="true" on a <button> passes every rule it has. It has no rule that says "a disclosure trigger's panel should be identified," because the ARIA spec doesn't require the identification; the Disclosure and Accordion authoring practices do. A perfect Lighthouse score tells you the markup you wrote is internally consistent. It does not tell you the markup you didn't write.
The fix
useId() generates a stable, hydration-safe id pair per row — no manual slug needed, and no collision risk across the eight FAQ instances:
// src/components/molecules/FaqItem.tsx
const [open, setOpen] = useState(false);
const id = useId();
const buttonId = `${id}-button`;
const panelId = `${id}-panel`;
The trigger moves inside an <h3> and picks up its id/aria-controls:
<h3 className="m-0">
<button
type="button"
id={buttonId}
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
aria-controls={panelId}
className="flex w-full items-start justify-between gap-2 px-6 pt-6 pb-6 text-left text-lg font-medium text-title-color"
>
{question}
{/* chevron unchanged */}
</button>
</h3>
className="m-0" on the <h3> matters more than it looks: Tailwind's preflight already zeroes heading margins and font size, but stating it explicitly is what stopped the wrapper from being a silent visual regression during review — the flex layout on the <button> fills the heading exactly as before.
The panel gets its id unconditionally, since aria-controls must always resolve to something in the DOM even while the panel is visually collapsed:
<div
id={panelId}
role={open ? "region" : undefined}
aria-labelledby={open ? buttonId : undefined}
className="grid transition-[grid-template-rows] duration-300"
style={{ gridTemplateRows: open ? "1fr" : "0fr" }}
>
The part most accordion tutorials get wrong: role="region" is not free
Almost every accordion guide tells you to put role="region" and aria-labelledby on every panel, unconditionally. The Accordion authoring practice is more careful than that: region turns an element into a page landmark, and a screen reader user can pull up a list of landmarks to jump around a page. An accordion with eight rows — ours — that marks all eight panels as regions all the time hands that user eight landmarks, most of them collapsed and carrying no visible content. That's landmark spam, not a convenience.
Our fix makes role="region" and aria-labelledby conditional on open. Collapsed panels are plain <div>s; only the panel currently showing an answer becomes a landmark. The id stays on unconditionally, because aria-controls has to resolve to a real element whether or not that element is currently exposed as a region — dropping the id when closed would make the trigger's aria-controls point at nothing.
What we deliberately left out: arrow-key navigation between headers
The Accordion authoring practice also documents an optional keyboard enhancement: Down/Up arrow to move focus between adjacent accordion headers, and Home/End to jump to the first or last. We did not add it, and the reason is a real distinction from the tabs pattern rather than an oversight. A tablist needs a roving tabindex and arrow keys because the tabs are mutually exclusive views of one region — cycling through them one Tab press at a time would be unbearable for a ten-tab strip, so our tabs fix makes the whole strip one stop. An accordion's rows are independent, standalone disclosure widgets a user may want to visit individually; plain Tab order through eight buttons is the same interaction model the native <details>/<summary> element uses, and nobody considers <details> inaccessible for lacking arrow-key jumps between separate elements. The arrow-key enhancement is real and some accordion implementations add it — it's just optional rather than a requirement the way roving tabindex is for tabs, and we chose the simpler, still-conformant option.
Common mistakes
| Mistake | Symptom | Fix |
|---|---|---|
aria-expanded with no aria-controls | Screen reader announces expanded/collapsed but not what expanded | Add matching id/aria-controls |
Bare <button>, no heading wrapper | Heading-navigation users can't jump the FAQ list | Wrap the trigger in <h3> (or the level that fits the page outline) |
role="region" on every panel, always | Landmark list fills with mostly-empty collapsed regions | Apply role="region" (and aria-labelledby) only while open |
Dropping the panel's id when collapsed | aria-controls on the trigger points at nothing | Keep id unconditional; only role/aria-labelledby are conditional |
| Trusting Lighthouse/axe-core to catch this | 100 on a fully static, crawlable page, pattern still incomplete | These are authoring-practice recommendations, not ARIA-required attributes — scanners don't check them |
<div onClick> instead of <button> | No keyboard focus, no default Enter/Space toggle | Use a real <button type="button"> |
Frequently asked questions
Can more than one panel be open at once in this accordion?
Yes — each FaqItem holds its own open state independently, so this is a multi-select accordion by construction. The WAI-ARIA pattern permits either single- or multi-select; ours never needed a parent component coordinating which row is active, which is also why there's no keyboard-roving-tabindex requirement here the way there is for tabs — each header is its own independent tab stop, and that's correct for a disclosure list rather than a tablist.
Do I need a library like Radix or @szhsin/react-accordion?
For a fixed list of question/answer rows, the pattern above is under fifteen lines of ARIA wiring on top of a useState. Reach for a library when you need what it earns its keep on: single-select-with-animation coordination across many panels, nested accordions, or nested nesting the nesting the roving-tabindex APIs get fiddly. We measure that hand-rolled-vs-library trade for this codebase's 91 components in when hand-rolling actually wins.
Is a <details>/<summary> element a simpler fix than ARIA roles on a <div>?
For plain expand/collapse with no animation requirement, native <details> gets you aria-expanded-equivalent semantics and keyboard support for free, with far less markup. It buys none of that for free the moment you need the grid-template-rows height animation this component uses — <details> can't animate its own open transition without extra wrapper elements and a bit of the same complexity back, which is why this component stays a styled <div> accordion rather than switching element types.
Does this fix change anything visually?
No — className="m-0" on the new <h3> cancels the only property a heading element adds by default in this codebase's reset, and every class that shapes the row (the flex layout, the padding, the grid-row transition) is unchanged. The eight rows on /pricing render pixel-identical; only the accessibility tree changed.
Templates where this pattern already ships
ASoc Vertex pairs a sales-analytics dashboard with a full sidebar shell, the kind of admin surface where a settings panel commonly collapses into an accordion. ASoc Crest is a classic sidebar admin across 5 dashboards and 8 app modules — enough surface area that a component library page is exactly where an accordion pattern like this belongs. ASoc Admin, the flagship build at 13 dashboards and 135+ pages, is the largest of the three if you want the pattern proven at scale.
Browse the full sets: React admin templates, Next.js admin templates, Tailwind admin templates. For the tabs half of this same audit, read React tabs: six requirements, and the three ours was missing, and for the wider point about automated scores versus real conformance, why a Lighthouse 100 is not WCAG conformance.
