Accordions & disclosure widgets

A disclosure widget is the simplest interactive pattern on the web: a button that shows or hides a region of content. An accordion is just a stack of them, often sharing a heading structure. Because the markup looks trivial, it is one of the most frequently broken patterns in the wild — a <div> with a click handler, a chevron that flips visually but says nothing, a panel that animates open while the toggle still reports “collapsed”. Each shortcut quietly removes the widget from the keyboard, the accessibility tree, or both.

The native pieces you need are already in HTML. A real <button> is focusable, operable with Enter and Space, and exposes a role for free. Add a single state attribute, aria-expanded, keep it in sync with the visible panel, and the widget is complete. This lesson works through the three defects that account for nearly every inaccessible accordion, and shows how little code it takes to do it right.

What you’ll learn

How to build the toggle on a native <button> so it is keyboard operable and exposes the right role; how to add aria-expanded and point the button at its panel with aria-controls; and — the part most often missed — how to keep aria-expanded in sync with the panel every time the state changes, so assistive technology always reports what is actually on screen.

Standards this lesson maps to
Standard Criterion Level What it requires
WCAG 2.2 2.1.1 Keyboard A All functionality is operable through a keyboard, including expanding and collapsing the panel.
WCAG 2.2 4.1.2 Name, Role, Value A The toggle exposes its name and button role, and its expanded/collapsed state is available to assistive tech and kept current.
WCAG 2.2 1.3.1 Info and Relationships A The relationship between the toggle and the region it controls is conveyed in the markup, not by position alone.
WCAG 2.2 2.4.7 Focus Visible AA The keyboard focus indicator on the toggle is visible, so users can see which header is active.
EN 301 549 9.2.1.1 / 9.4.1.2 (incorporates WCAG) European harmonised standard; references the WCAG A/AA set including keyboard and name/role/value.
Section 508 502.3 / 504 (incorporates WCAG A & AA) US federal ICT must meet WCAG 2.0 Level A and AA, including keyboard operability and exposed state.
ADA Title II WCAG 2.1 AA (DOJ rule) AA US state/local government web content must conform to WCAG 2.1 AA.

The three problems we’ll fix

Each card below isolates one common accordion defect. For every issue you get a plain-language statement of the problem, a Bad example (shown as escaped, non-running code so it can’t harm this page), a Good example, the copyable Code, and an ordered fix.

Toggle missing aria-expanded

WCAG 2.2 · 4.1.2 A EN 301 549 Section 508 ADA Title II

The toggle is a real <button> and the panel shows and hides correctly, but the button never declares whether it is open or closed. A sighted user reads the answer from the chevron and the visible panel; a screen reader user hears only “Shipping & returns, button” and has no way to know the content is already expanded, or that pressing it will reveal more. The state has to be exposed programmatically. aria-expanded="false" announces “collapsed”, aria-expanded="true" announces “expanded”, and pairing the button with the panel via aria-controls tells assistive technology which region it operates.

Bad

A correct button with a working click handler — but no state. Assistive technology cannot tell whether the panel is open or closed.

bad-no-expanded.html
<h3>
  <button type="button" class="accordion-trigger">
    Shipping & returns
  </button>
</h3>
<div class="accordion-panel">
  <p>We ship within 2 business days…</p>
</div>

Good

The button carries aria-expanded to announce its state and aria-controls to name the panel it operates. The panel’s hidden attribute matches the state, so the two never disagree.

good-expanded.html
<h3>
  <button type="button" class="accordion-trigger"
          aria-expanded="false" aria-controls="panel-ship">
    Shipping & returns
  </button>
</h3>
<div id="panel-ship" class="accordion-panel" hidden>
  <p>We ship within 2 business days…</p>
</div>

Code

A single disclosure widget needs only this much script: flip aria-expanded and toggle the panel’s hidden attribute together on each click.

disclosure.js
const btn = document.querySelector('.accordion-trigger');
const panel = document.getElementById('panel-ship');

btn.addEventListener('click', () => {
  const open = btn.getAttribute('aria-expanded') === 'true';
  btn.setAttribute('aria-expanded', String(!open));
  panel.hidden = open;
});

How to fix

  1. Add aria-expanded to the toggle, set to "false" when the panel starts collapsed and "true" when it starts open.
  2. Point the button at its panel with aria-controls referencing the panel’s id.
  3. Keep the visible state and the attribute in agreement — when the panel is collapsed, give it the hidden attribute too.
  4. Verify in the accessibility tree that the button is announced as “collapsed” or “expanded” and that the value tracks the panel.

Toggle built on a <div>, not keyboard operable

WCAG 2.2 · 2.1.1 A 4.1.2 A EN 301 549 Section 508 ADA Title II

A <div> or <span> with a click handler looks identical to a button on screen, but it is not in the tab order, carries no role, and ignores Enter and Space. A keyboard or switch user simply cannot reach or operate it, and a screen reader announces it as meaningless static text. The honest fix is to use a real <button>, which is focusable, operable, and exposes the button role for free. If a generic element truly must be used, you have to manually replace everything the button gave you: role="button", tabindex="0", and key handlers for both Enter and Space.

Bad

A clickable <div>. Mouse users can open it; keyboard, switch, and screen reader users are locked out entirely (2.1.1, 4.1.2).

bad-div-toggle.html
<div class="accordion-trigger" onclick="toggle()">
  Shipping & returns
</div>
<div class="accordion-panel">
  <p>We ship within 2 business days…</p>
</div>

Good

Swap the <div> for a native <button>. It is automatically in the tab order, responds to Enter and Space, and reports the button role — no extra ARIA or key handling required.

good-button-toggle.html
<h3>
  <button type="button" class="accordion-trigger"
          aria-expanded="false" aria-controls="panel-ship">
    Shipping & returns
  </button>
</h3>
<div id="panel-ship" class="accordion-panel" hidden>
  <p>We ship within 2 business days…</p>
</div>

Code

If a generic element is unavoidable, you must rebuild what the button provided: role, focusability, and keyboard activation. This is more code and more risk — prefer the native button above.

div-with-role.html
<div role="button" tabindex="0" class="accordion-trigger"
     aria-expanded="false" aria-controls="panel-ship">
  Shipping & returns
</div>

<script>
trigger.addEventListener('keydown', (e) => {
  if (e.key === 'Enter' || e.key === ' ') {
    e.preventDefault();   // Space scrolls the page otherwise
    trigger.click();
  }
});
</script>

How to fix

  1. Use a native <button type="button"> for the toggle so it is focusable and keyboard operable by default.
  2. Wrap the button in the appropriate heading (<h3> in an accordion) so the headings still form a navigable outline.
  3. Only if a generic element is forced on you, add role="button", tabindex="0", and a keydown handler for Enter and Space (calling preventDefault for Space).
  4. Tab to the toggle and confirm it receives focus, shows a visible focus ring, and activates with both Enter and Space.

Panel toggles but aria-expanded never updates

WCAG 2.2 · 4.1.2 A EN 301 549 Section 508 ADA Title II

This is the subtlest failure: the button has aria-expanded, so an audit checklist ticks the box — but the value is hard-coded and the click handler only toggles the panel’s visibility, never the attribute. Now the markup actively lies. The panel is open on screen while the button still reports aria-expanded="false", so a screen reader user is told the content is collapsed when it is plainly showing, and pressing the button to “open” it closes it instead. A stale state attribute is worse than a missing one, because it produces confident, wrong announcements. Every code path that changes the panel must update aria-expanded in the same step.

Bad

The handler shows the panel but leaves aria-expanded frozen at "false". State and reality drift apart on the very first click (4.1.2).

bad-stale-state.js
<button aria-expanded="false" aria-controls="panel-ship">…</button>

<script>
btn.addEventListener('click', () => {
  // Only the panel changes — the attribute is never touched.
  panel.classList.toggle('is-open');
});
</script>

Good

One source of truth: read the current state from the attribute, then write the new state to both the attribute and the panel together, so they can never disagree.

good-sync-state.js
btn.addEventListener('click', () => {
  const willOpen = btn.getAttribute('aria-expanded') !== 'true';
  btn.setAttribute('aria-expanded', String(willOpen));
  panel.hidden = !willOpen;
});

Code

Funnel every state change through one function and call it from each path — the click, a “collapse all” control, a deep-link that opens a panel. The attribute can never fall out of sync because nothing sets the panel without it.

single-setter.js
function setState(btn, panel, open) {
  btn.setAttribute('aria-expanded', String(open));
  panel.hidden = !open;
}

btn.addEventListener('click', () => {
  const open = btn.getAttribute('aria-expanded') === 'true';
  setState(btn, panel, !open);
});

// Collapse-all reuses the same setter — state stays in sync.
collapseAll.addEventListener('click', () =>
  panels.forEach(([b, p]) => setState(b, p, false))
);

How to fix

  1. Update aria-expanded in the same step that you show or hide the panel — never one without the other.
  2. Derive the new state by reading the current aria-expanded value, so the attribute is your single source of truth.
  3. Route every path that opens or closes a panel — click, collapse-all, deep-link — through one setter function.
  4. Test by opening a panel and re-reading the button in a screen reader: it must now say “expanded”, and toggling again must return it to “collapsed”.

Recap

  • Build the toggle on a native <button> so it is focusable and operable with Enter and Space for free (2.1.1, 4.1.2).
  • Add aria-expanded to the button and point it at its panel with aria-controls so the relationship and state are exposed (4.1.2, 1.3.1).
  • Update aria-expanded every time the panel opens or closes — a state attribute that never changes is worse than none (4.1.2).
  • Don’t reinvent the button on a <div> or <span>; if you must, you owe it role, tabindex, and key handlers, and you’ll still get it subtly wrong.

The same fixes satisfy WCAG, EN 301 549, Section 508, and ADA Title II at once — one real button, one state attribute kept in sync, and the widget works for everyone.