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.2AEN 301 549Section 508ADA 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.
Add aria-expanded to the toggle, set to "false"
when the panel starts collapsed and "true" when it starts open.
Point the button at its panel with aria-controls referencing the
panel’s id.
Keep the visible state and the attribute in agreement — when the panel is
collapsed, give it the hidden attribute too.
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.1A4.1.2AEN 301 549Section 508ADA 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.
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.
Use a native <button type="button"> for the toggle so it is
focusable and keyboard operable by default.
Wrap the button in the appropriate heading (<h3> in an
accordion) so the headings still form a navigable outline.
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).
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.2AEN 301 549Section 508ADA 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.
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
Update aria-expanded in the same step that you show or hide the
panel — never one without the other.
Derive the new state by reading the current aria-expanded value,
so the attribute is your single source of truth.
Route every path that opens or closes a panel — click, collapse-all,
deep-link — through one setter function.
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.