Search topics
Show and hide buttons that don't say whether they are open
A button opens and closes a panel, but open and closed live only in a CSS class, so assistive technology hears the same button either way.
class="filter-toggle is-open" The problem
Screen reader users can press the button but can't tell whether the panel is open, or whether pressing it did anything.
A product list has a Filters button above the results. Pressing it opens a panel of checkboxes, "In stock only" and "On sale", and the chevron beside the word flips to point up. Pressing it again closes the panel. Everything works with a mouse and with the keyboard. A screen reader user tabs to the button and hears "Filters, button". They press Enter and hear nothing new. They press it again: still nothing. They can't tell whether the filters are now open, or whether the panel was open before they arrived.
Success criterion 4.1.2 requires that states a user can change are available to assistive technology, and that it is told when they change. Open and closed is such a state. Here the script toggles an is-open class on the button and the panel, and the CSS draws the chevron and shows the panel from that class. The button has a name and a role, but nothing in the accessibility tree says expanded or collapsed, so the change is drawn and never announced.
The same failure shows up as a mobile menu button, a "Show delivery details" link, or a "More options" button with a row of icons under it. Wherever a button shows and hides content beside it, its state has to be exposed.
The panel often opens out of view: below the fold on a phone, or outside the magnified part of the screen. Then the button's state is the only cue that anything happened.
2 of 10 groups affected
- No vision (affected)
- Low vision (affected)
- Colour vision (not affected)
- No hearing (not affected)
- Hard of hearing (not affected)
- No speech (not affected)
- Motor (not affected)
- Reach, strength (not affected)
- Cognitive (not affected)
- Seizures (not affected)
- Without vision Screen reader users hear "Filters, button" whether the panel is open or closed, so they can't tell what pressing it did, or whether the filters are there to read.
- Limited vision People using screen magnification may not see a panel open outside the magnified area, and a screen reader used alongside gives them no state to confirm it.
A scanner sees a native button with a name and reports nothing. It can't know that the button shows and hides a panel, so it never asks for aria-expanded. The only related rule, aria-valid-attr-value, fires after a fix, when aria-controls points at a panel that isn't in the page. Code assistants often swap the label between "Show filters" and "Hide filters" instead, which changes the name rather than reporting a state, or add aria-expanded once in the markup and never update it. Toggle the button and listen.
Try it
Press Tab to reach Filters, then Enter to open it.
The panel shows what a screen reader announces. Can you tell whether the filters are open?
Broken Can you fix this?
Screen reader says:
(Tab to Filters)A recreated demo. The broken version is intentionally inaccessible.
The fix
Expose open and closed with aria-expanded on the button, or with details and summary.
- 1Keep the open state in step with the panel on every toggle.
- 2Name the button for what it shows, and keep the name fixed.
- 3Put the panel right after the button, and out of reach when closed.
<button type="button" class="filter-toggle"> Filters <svg class="chevron" aria-hidden="true">…</svg></button><div class="filter-panel"> <label><input type="checkbox" name="stock"> In stock only</label> <label><input type="checkbox" name="sale"> On sale</label></div>Aria-expanded on the existing button
<button type="button" class="filter-toggle" aria-expanded="false" aria-controls="filters"> Filters <svg class="chevron" aria-hidden="true">…</svg></button><div class="filter-panel" id="filters" hidden> <label><input type="checkbox" name="stock"> In stock only</label> <label><input type="checkbox" name="sale"> On sale</label></div>Why this fix: Style from the state, not a class
The button is reachable and the closed panel is properly hidden, but open and closed exist only as a class. A screen reader announces "Filters, button" in both states and says nothing when the panel opens.
The button is now announced as "Filters, button, collapsed", and "expanded" after it is pressed. The chevron is drawn from the same attribute, so the picture and the announcement can't disagree, and a forgotten attribute shows as a chevron that never flips. The hidden attribute takes the closed panel out of the tab order and out of what a screen reader can reach. It works only while no rule in your CSS sets display on the panel; if one does, add .filter-panel[hidden] { display: none; }.
Both pass. Adding aria-expanded is the smaller change when the toggle already exists: two attributes, one line of script and one selector, and the panel can stay where the layout puts it. Its cost is that the script must update the attribute on every path that opens or closes the panel, including a close button or a route change. details and summary need no script and nothing to keep in step, so choose them when you build the toggle new and the panel can sit inside the same element, right after its trigger. They are harder to fit when the button lives in a toolbar and the panel spans the page below it, or when the panel must animate open.
The state is half of what the button has to say; the other half is what it controls. These choices decide whether that comes across.
- Name the button for what it shows, such as "Filters" or "Delivery details", not "Show", "More" or a lone chevron. Keep the name fixed: "Hide filters, expanded" says the same thing twice, and swapping the label without a state is often not announced while focus stays on the button.
- Put the panel directly after the button in the DOM.
aria-controlsis acted on by few screen readers, so reading order is what really connects the two. A panel placed at the end of the page leaves people hunting for what just opened. - Keep the panel in the page when it is closed. If a framework renders it only while open,
aria-controlspoints at nothing when closed and axe reportsaria-valid-attr-value. Render it withhiddeninstead, or setaria-controlsonly while the panel exists. - Don't make it a menu. A disclosure is a button and a panel;
role="menu"andaria-haspopuppromise arrow-key behaviour the panel doesn't have.
If the page works without script, set aria-expanded and hidden from the script at start-up, so the panel stays open and usable when the script fails to load. Draw the chevron in currentColor, so it stays visible in forced colors.
Fixing this with an AI coding assistant? Get this guide as Markdown
Verify the fix
5 checks, no mouse.
Test with: Keyboard, Screen reader, Zoom, Forced colors
Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat after the shared toggle component changes.
Limits
This is a starting pattern, not a guarantee for every application. Screen readers word a summary differently, and some announce it as a disclosure triangle rather than a button. The script is a minimal sketch without animation or a close button inside the panel. Test the complete filtering journey with your target assistive technology.
This is a recreated teaching example, not a finding about a named client. The code is a starting pattern: test it in your own product. No completed assistive-technology test is claimed here.
Keep this fixed as your code changes.
Turn guides like this into rules your coding agents and CI follow, then have the result retested with a keyboard and screen readers.