# Show and hide buttons that don't say whether they are open

Source: https://easeweb.dev/learn/show-hide-buttons
Topics: Tabs, accordions and disclosures > Show and hide buttons say what they control (WCAG 4.1.2)
The fix: Either add aria-expanded to the existing button, keep it in step with the panel and draw the chevron from it, or rebuild the toggle as details and summary, which report open and closed by themselves. Either way, name the button for what it shows and put the panel right after it.
Test with: keyboard, screen reader, zoom, forced colors
References: https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/ https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-expanded https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/details

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.

## What happens

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.

## Why it fails

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.

## Who is affected

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.

## What automation and AI miss

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.

## Before: a class that draws open and closed

```html
<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>
```

```css
.filter-panel { display: none; }
.filter-panel.is-open { display: block; }
.filter-toggle.is-open .chevron { rotate: 180deg; }
```

```js
toggle.addEventListener('click', () => {
  toggle.classList.toggle('is-open');
  panel.classList.toggle('is-open');
});
```

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.

## After: aria-expanded on the existing button

```html
<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>
```

```css
.filter-toggle[aria-expanded="true"] .chevron { rotate: 180deg; }
```

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

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; }`.

## After: a native disclosure

```html
<details class="filters">
  <summary>Filters <svg class="chevron" aria-hidden="true">…</svg></summary>
  <div class="filter-panel">
    <label><input type="checkbox" name="stock"> In stock only</label>
    <label><input type="checkbox" name="sale"> On sale</label>
  </div>
</details>
```

```css
.filters summary { list-style: none; }
.filters summary::-webkit-details-marker { display: none; }
.filters[open] .chevron { rotate: 180deg; }
```

The browser keeps the state, exposes it as expanded or collapsed, toggles it with Enter, Space and a click, and hides the closed contents, with no script at all. The chevron is drawn from the `open` attribute, the same value the browser reports. The panel has to sit inside the `details`, directly after the `summary`.

## Which option to use

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.

## Implementation decisions

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-controls` is 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-controls` points at nothing when closed and axe reports `aria-valid-attr-value`. Render it with `hidden` instead, or set `aria-controls` only while the panel exists.
- Don't make it a menu. A disclosure is a button and a panel; `role="menu"` and `aria-haspopup` promise 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.

## Verify the fix

1. With a screen reader, Tab to the toggle and confirm it announces the name, the role and collapsed.
2. Press Enter, then Space, and confirm each announces the new state once, and that the chevron matches what is announced.
3. With the panel closed, Tab past the toggle and confirm nothing inside the panel takes focus; open it and confirm the next Tab, or reading on, reaches the panel's first control.
4. Where the panel is rendered by a framework or loaded later, inspect the toggle with the panel open and closed, and run axe in both states, to confirm any `aria-controls` points at an element that exists.
5. In forced colors and at 200% zoom, confirm open and closed can still be told apart and the panel opens where people will find it.

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.
