Accessible Accordion & Disclosure Pattern Guide
Accordions and “show more” toggles are everywhere — and quietly broken almost as often. This guide covers the aria-expanded state, the button-in-heading structure, the native <details> element, and single vs multi-expand behavior, with copy-ready HTML, JavaScript, and React mapped to WCAG 2.2.
Why Expand/Collapse Widgets Are So Often Wrong
A disclosure looks like the simplest interactive widget on the page: click a header, content appears. That simplicity is a trap. The most common build is a <div> with an onClick that toggles a CSS class — which gives a mouse user exactly what they expect and gives everyone else nothing. The toggle is not focusable, the keyboard cannot operate it, and a screen reader never announces whether the section is open, closed, or a control at all.
Getting it right takes three things: a real <button>, the aria-expanded state kept in sync as the region opens and closes, and — for a multi-section accordion — each button wrapped in a heading so the structure is navigable. The WAI-ARIA Authoring Practices Disclosure pattern and its Accordion pattern specify exactly what each needs. This guide turns both into code you can paste in — and shows you when to skip ARIA entirely and let the browser do the work.
Try the native element first. If you do not need to animate the transition or precisely control the markup, <details> and <summary> give you a fully accessible disclosure with no JavaScript and no ARIA. Build a custom widget only when the native one genuinely cannot do the job.
The WCAG 2.2 Criteria Accordions Satisfy
| Criterion | Level | What the accordion must do |
|---|---|---|
| 1.3.1 Info & Relationships | A | Headers are real headings; each button is linked to the region it controls. |
| 2.1.1 Keyboard | A | Every header is reachable and toggles with Enter and Space. |
| 2.4.3 Focus Order | A | Focus moves through headers in a logical order; opening a panel keeps focus on its header. |
| 2.4.7 Focus Visible | AA | The focused header shows a clear, visible focus indicator. |
| 4.1.2 Name, Role, Value | A | The button exposes its name, the button role, and the live aria-expanded state. |
See the full picture in the WCAG 2.2 Level AA requirements and work through the interactive WCAG 2.2 checklist.
Disclosure, Accordion, or Tabs? Pick the Right Pattern
These three patterns look similar but mean different things to assistive technology. Choosing the wrong one is itself an accessibility bug, because it misrepresents the structure. Match the pattern to how the content actually behaves.
Disclosure
One button that shows or hides a single region — “Read more,” an expandable filter, a details toggle. No grouping, no headings required.
Accordion
Several disclosures grouped under headings — an FAQ list, stacked settings sections. Sections can expand independently; each header is a real heading.
Tabs
Exactly one panel of related peer content visible at a time, with a roving-tabindex tab strip. Use the tabs pattern instead.
Rule of thumb: if sections can be open at the same time, or the content should stack and reflow on a narrow screen, use an accordion. If the sections are peers and only one should ever show, and they share a heading area, use tabs. If there is just one thing to reveal, it is a disclosure.
1. Start With the Native <details> Element
Before writing a single line of ARIA, ask whether <details> and <summary> can do the job. They ship a complete, accessible disclosure: the <summary> is the toggle, the browser tracks the open state and exposes it to assistive technology, and keyboard support works out of the box. No aria-expanded, no click handler, no state to keep in sync.
<!-- A complete, accessible disclosure — zero JavaScript -->
<details>
<summary>Shipping & returns</summary>
<p>
Orders ship within two business days. Returns are free
within 30 days of delivery.
</p>
</details>
<!-- Add the "open" attribute to render it expanded by default -->
<details open>
<summary>What sizes are available?</summary>
<p>We stock XS through 3XL in every colourway.</p>
</details>Modern browsers even support an exclusive accordion natively: give a group of <details> elements the same name attribute and opening one closes the others — no JavaScript required.
<!-- Shared name = only one open at a time (Chrome/Edge/Safari/Firefox 2024+) -->
<details name="faq"><summary>How do refunds work?</summary><p>...</p></details>
<details name="faq"><summary>Where do you ship?</summary><p>...</p></details>
<details name="faq"><summary>Can I change my order?</summary><p>...</p></details>When the native element is not enough
- You need to animate the open/close transition (native
<details>snaps open instantly). - You need full control over the header markup, or the toggle must be a heading that contains other elements.
- Your framework owns the open state and rendering (React, Vue, Angular) and you want it in component state.
- You need behavior
<details>does not model — e.g. a “collapse all” control tied to shared state.
In those cases, build the ARIA disclosure below — but remember you are re-creating what the browser gave you for free, so match its behavior exactly.
2. The ARIA Disclosure: A Button + aria-expanded
When you build your own, the disclosure is just two elements: a real <button> and the region it controls. The button carries aria-expanded to announce the state and aria-controls to point at the region. Collapse the region with the hidden attribute so it leaves the accessibility tree entirely.
<button aria-expanded="false" aria-controls="more-details">
More product details
</button>
<div id="more-details" hidden>
<p>Full specifications, materials, and care instructions…</p>
</div>That is the entire pattern. aria-expanded="false" tells a screen reader the button is collapsed, so it announces “More product details, collapsed, button.” The one job of your JavaScript is to flip aria-expanded and toggle hidden together, every time — the state on the button and the visibility of the region must never drift apart.
3. The Accordion: Disclosures Wrapped in Headings
An accordion is a group of disclosures, and it adds one structural rule: each toggle button must live inside a real heading element. That is what lets a screen reader user pull up the page's heading list and jump straight to any section — the single most useful way to navigate a long accordion.
| Element | Markup | Key attributes |
|---|---|---|
| Each header | <h3> wrapping a <button> | The heading level fits the page outline; the button holds aria-expanded and aria-controls. |
| Each toggle button | native <button> | aria-expanded="true|false" (current state); aria-controls="<panel-id>" (the region it shows/hides). |
| Each panel | role="region" (optional) | id matching the button's aria-controls; aria-labelledby="<button-id>"; hidden when collapsed. |
Here is a complete, conformant accordion. Note the <h3> wrapping every button, the paired aria-controls / aria-labelledby ids, and the hidden attribute on collapsed panels.
<div class="accordion">
<h3>
<button id="acc-ship" aria-expanded="true"
aria-controls="panel-ship">
Shipping & delivery
</button>
</h3>
<div id="panel-ship" role="region" aria-labelledby="acc-ship">
<p>Orders ship within two business days…</p>
</div>
<h3>
<button id="acc-returns" aria-expanded="false"
aria-controls="panel-returns">
Returns & refunds
</button>
</h3>
<div id="panel-returns" role="region"
aria-labelledby="acc-returns" hidden>
<p>Free returns within 30 days…</p>
</div>
<h3>
<button id="acc-warranty" aria-expanded="false"
aria-controls="panel-warranty">
Warranty
</button>
</h3>
<div id="panel-warranty" role="region"
aria-labelledby="acc-warranty" hidden>
<p>Two-year limited warranty on all products…</p>
</div>
</div>The role="region" and aria-labelledby on each panel are a recommended enhancement, not strictly required: they turn each open panel into a named landmark the user can navigate to. On accordions with many panels this can add landmark noise, so the APG suggests reserving role="region" for accordions with a small number of sections. The heading wrapper, however, is not optional. Set the heading level to fit the page outline — see the ARIA roles & attributes reference for how each attribute is exposed.
4. The Keyboard Interaction Model
The good news: an accordion built from real buttons needs almost no keyboard code. Because each header is a native <button>, Tab reaches it and Enter / Space toggle it for free — there is no roving tabindex here, unlike the tabs pattern. Arrow-key navigation between headers is an optional convenience.
| Key | Action |
|---|---|
| Tab | Moves focus to the next header button, and out of the accordion when past the last one. Every header is in the normal Tab order — no roving tabindex. |
| Enter or Space | Toggles the focused header: expands its panel if collapsed, collapses it if open. Free when the header is a real <button>. |
| Down Arrow / Up Arrow | Optional (APG): moves focus to the next / previous header button without leaving the accordion. A convenience for accordions with many sections, never the only way in. |
| Home / End | Optional (APG): moves focus to the first / last header button in the accordion. |
The critical difference from tabs: in an accordion, Tab must always move between headers, and the Arrow keys are extra. In tabs, the Arrow keys are the primary navigation and the whole strip is a single Tab stop. Do not copy the roving-tabindex model from the tabs guide into an accordion — see the broader technique in the keyboard accessibility guide.
5. Wiring It Up in Vanilla JavaScript
This handler toggles any accordion header. One function reads and flips aria-expanded and the panel's hidden attribute together — the single source of truth that keeps state and visibility in sync.
const accordion = document.querySelector(".accordion")
const headers = [...accordion.querySelectorAll("button[aria-controls]")]
function toggle(button) {
const expanded = button.getAttribute("aria-expanded") === "true"
button.setAttribute("aria-expanded", String(!expanded))
const panel = document.getElementById(button.getAttribute("aria-controls"))
panel.hidden = expanded // was open -> now hidden, and vice-versa
}
headers.forEach((button) => {
// Enter/Space come free with a real <button>; just handle the toggle
button.addEventListener("click", () => toggle(button))
})
// Optional APG enhancement: Up/Down/Home/End move focus between headers
accordion.addEventListener("keydown", (e) => {
const i = headers.indexOf(document.activeElement)
if (i === -1) return
let next = null
if (e.key === "ArrowDown") next = (i + 1) % headers.length
else if (e.key === "ArrowUp") next = (i - 1 + headers.length) % headers.length
else if (e.key === "Home") next = 0
else if (e.key === "End") next = headers.length - 1
else return
e.preventDefault()
headers[next].focus() // Tab still works — this is extra, not required
})For an exclusive (single-expand) accordion, collapse the others inside toggle()before opening the new one — and never disable the open header's button, so the user can always collapse the current section.
6. An Accessible Accordion in React
The roles and states are identical — React derives them from state. Track which panels are open in a Set (so multiple can expand), generate stable ids with useId, and render the heading wrapper explicitly.
function Accordion({ items }) {
const [open, setOpen] = React.useState(() => new Set([0]))
const uid = React.useId()
function toggle(i) {
setOpen((prev) => {
const next = new Set(prev) // multi-expand
next.has(i) ? next.delete(i) : next.add(i)
return next
// For single-expand: return next.has(i) ? new Set() : new Set([i])
})
}
return (
<div>
{items.map((item, i) => {
const isOpen = open.has(i)
return (
<div key={i}>
<h3>
<button
id={`${uid}-h-${i}`}
aria-expanded={isOpen}
aria-controls={`${uid}-p-${i}`}
onClick={() => toggle(i)}
>
{item.title}
</button>
</h3>
<div
id={`${uid}-p-${i}`}
role="region"
aria-labelledby={`${uid}-h-${i}`}
hidden={!isOpen}
>
{item.content}
</div>
</div>
)
})}
</div>
)
}In production, a headless, WAI-ARIA-tested library — Radix UI Accordion, React Aria Disclosure, or Headless UI — ships the state, heading structure, and optional keyboard handling for you. The React accessibility guide covers the useId pattern in depth, and the same approach applies in the Vue and Angular accessibility guides.
7. Single-Expand vs Multi-Expand
Whether one panel or many can be open at once is a UX decision — both are fully accessible. The choice changes how forgiving the accordion feels, not whether it conforms.
Multi-expand
Any number of panels can be open independently. The friendlier default — nothing the user opened ever disappears on its own.
Use for FAQs, settings, and documentation where people compare or scan across sections.
Single-expand
Opening one panel collapses the rest. Keeps a long list compact, but can surprise users when a section they opened silently closes.
Use for space-constrained UIs — but never disable the open header's button, and update aria-expanded on the closed panel too.
The one accessibility trap in single-expand mode is the auto-close: when opening panel four closes panel two, panel two's aria-expanded must flip back to "false" and its content must gain the hidden attribute — otherwise a screen reader still thinks it is open. If you only need exclusive behavior and no animation, the native <details name> approach from section 1 handles all of this for you.
How to Test an Accessible Accordion
Automated scanners can confirm a button exists and aria-controls points at a real id, but they cannot tell you the state stays in sync. Run this manual pass on every accordion:
- Tab to each header. Every toggle receives focus with a visible indicator, in reading order — no header is skipped or trapped.
- Press Enter, then Space. Both expand and collapse the panel. Focus stays on the header after toggling — it does not jump into the panel.
- Listen with a screen reader. You should hear the header's name, “button,” and “collapsed” or “expanded” that flips as you toggle. Verify with the screen reader testing guide.
- Pull up the heading list. Each section appears as a heading at the right level, so the user can jump straight to it.
- Confirm collapsed content is gone. A collapsed panel is unreachable by Tab and by the virtual cursor — proof it left the accessibility tree.
Then layer automated checks on top: axe-core flags a missing aria-controls target or an aria-expanded on a non-interactive element. See automated vs manual testing for where each fits, then scan the live page with the URL accessibility auditor.
Common Accordion Mistakes & How to Fix Them
| Anti-pattern | Why it fails | The fix |
|---|---|---|
| A <div> with an onClick handler as the toggle. | It is not focusable or keyboard-operable, and a screen reader announces nothing (2.1.1, 4.1.2). | Use a real <button>. It is focusable, Enter/Space work, and it is announced as a control. |
| No aria-expanded on the toggle. | The user cannot tell if the section is open or closed, or that the button reveals content (4.1.2). | Add aria-expanded and flip it true/false in JS every time you toggle the panel. |
| aria-expanded put on the panel instead of the button. | State is announced on the wrong element; the button stays stateless to assistive tech. | aria-expanded always lives on the interactive control the user activates — the button. |
| Accordion headers are buttons with no heading wrapper. | Screen reader heading navigation skips every section; the structure is invisible (1.3.1). | Wrap each header button in a heading: <h3><button aria-expanded>...</button></h3>. |
| Collapsed panels hidden with height:0 or opacity only. | The content stays in the accessibility tree, so screen reader and Tab users still reach it. | Use the hidden attribute or display:none so collapsed content leaves the tree entirely. |
| A rotating chevron is the only signal that a section is open. | The visual-only cue is invisible to screen readers and can fail contrast (1.4.1, 4.1.2). | Convey state with aria-expanded; the icon is decorative (aria-hidden) reinforcement only. |
Accessible Accordion Checklist
- Right pattern. Sections can expand independently — not one-at-a-time peer panels (use tabs). Consider native
<details>first. - Real buttons. Every toggle is a
<button>, so it is focusable and Enter/Space work with no extra code. - State is live.
aria-expandedsits on the button and flipstrue/falseon every toggle. - Headings wrap toggles. Each header is
<h3><button>…</button></h3>at a level that fits the page outline. - Linked regions.
aria-controlspoints at each panel id; panels usearia-labelledbyback to their button. - Collapsed = hidden. Closed panels use the
hiddenattribute so they leave the accessibility tree. - Focus visible. The focused header shows a clear indicator (2.4.7).
Work through the full WCAG 2.2 checklist to see accordions in the context of every other requirement.
Check Your Accordion on a Live Page
Scan any page with our free axe-core-powered auditor to catch a missing aria-expanded, a broken aria-controls target, or a toggle that is not a real button — then run the manual keyboard pass above.
Frequently Asked Questions
What is the difference between a disclosure and an accordion?▾
A disclosure is the atomic pattern: a single button that shows or hides one region of content, like a "Read more" toggle or an expandable filter. An accordion is a set of disclosures grouped under headings, where each header expands its own panel — the classic FAQ list is an accordion. The accessibility building blocks are the same: a real button with aria-expanded that controls a region. The accordion just adds one rule on top — each header button must be wrapped in a real heading element (h2, h3, and so on) so screen reader users can jump between sections with heading navigation. If you only ever have one expandable region, you have a disclosure, not an accordion, and you do not need the heading wrapper.
What does aria-expanded do, and where does it go?▾
aria-expanded is the single most important attribute in this pattern, and the most commonly forgotten. It goes on the button the user activates — never on the panel — and it announces the current state: aria-expanded="true" when the region is open, aria-expanded="false" when it is collapsed. A screen reader reads it as part of the control, so the user hears "Shipping, collapsed, button" and knows that pressing it will reveal content. Without aria-expanded, the button is announced with no state at all: the user cannot tell whether activating it opens something, closes something, or navigates away. You must update the attribute in JavaScript every time you toggle the region — a static aria-expanded="false" that never changes is as broken as having none.
Should I use the native details and summary elements or build my own?▾
Reach for the native details and summary elements first. They give you a fully keyboard-operable, screen-reader-announced disclosure with zero JavaScript: the summary is the toggle, the browser manages the open state and exposes it to assistive technology, and Enter and Space work automatically. Build a custom ARIA disclosure only when details cannot do what you need — for example, when you need to animate the open and close transition (details snaps open), style the disclosure triangle beyond what the marker allows, control the exact markup of the heading, or coordinate an accordion where opening one section closes another with shared state and framework rendering. When you do build your own, you are re-implementing what details gives you for free, so match its behavior exactly: a button, aria-expanded, and a controlled region.
Do accordion headers need to be real headings?▾
Yes. In a true accordion — several collapsible sections in a list — each toggle should be a button wrapped in a heading element: <h3><button aria-expanded>...</button></h3>. The heading level should reflect the section's place in the page's outline, so if the accordion sits under an h2, its headers are h3s. This matters because screen reader users navigate long pages by pulling up a list of headings or pressing the "next heading" key; if the accordion headers are plain buttons with no heading, the whole section is invisible to that navigation and users have to Tab through everything. A single standalone disclosure does not need a heading wrapper — the heading requirement is specific to the accordion, where the headers are structural landmarks in the content.
What keyboard support does an accordion need?▾
The only required keys are Enter and Space to toggle the focused header — and if each header is a real button, you get both for free. Tab moves between the header buttons in document order, exactly like any other set of buttons, so an accordion needs no roving tabindex. The ARIA Authoring Practices Guide lists optional enhancements for accordions with many sections: Up and Down arrows to move focus between headers, Home and End to jump to the first and last header, and sometimes Ctrl+Page Up/Down. These are nice-to-have, not required, and unlike the tabs pattern they must not be the only way to reach a header — Tab always has to work. Keep it simple: real buttons plus correct aria-expanded is a conformant accordion.
Should an accordion allow multiple panels open at once?▾
That is a UX decision, and both are accessible. A multi-expand accordion lets the user open any number of panels independently and is usually the friendlier default — it lets people compare sections and never hides content they just opened. A single-expand (exclusive) accordion opens one panel and closes the others; it keeps a long list compact but can be frustrating because opening section four silently collapses section two. If you do build an exclusive accordion, never disable the open header's button — the user should still be able to collapse the current section — and make sure aria-expanded is updated on both the newly opened and the automatically closed headers. Modern browsers also support exclusive behavior natively with the name attribute on grouped details elements.
Essential Accessibility Resources
Comprehensive tools, checklists, and guides to help you create inclusive digital experiences