In a disclosure widget where a button shows and hides a panel, what does aria-expanded communicate, which element must carry it, and what do its values mean?
answer
- it lives on the trigger, not the panel
- three conditions, not two
- strings, not HTML boolean presence
- absent means "not expandable at all"
- visibility and attribute flip together
basics
~20 saria-expanded reports whether the content a control governs is currently open. It belongs on the interactive control itself, not on the panel, and takes the string values "true" or "false" — omitting it means the control is not expandable at all.
solid answer
~40 s`aria-expanded` is a state that tells assistive technology whether the thing this control opens is currently showing. It goes on the **control** — the `<button>` the user activates — never on the panel being revealed, because the user encounters the button first and needs to know what activating it will do. The values are the strings `"true"` and `"false"`; a screen reader typically announces the button as "expanded" or "collapsed". Leaving the attribute off entirely is *not* the same as `"false"`: absent means the control has no expandable relationship at all, so nothing is announced. The obligation this creates is synchronisation — every time your code shows or hides the panel it must flip the attribute in the same step. A stale `aria-expanded="false"` on an open menu is worse than never having added it.
go deeper
Remember the one-line rule: aria-expanded goes on the button the user presses, holds the string "true" or "false", and must change whenever the panel opens or closes.
Explain the three-condition model — true, false, and absent meaning not expandable — and why the attribute must be updated in the same code path that changes visibility.
Demonstrate that you audit for drift: describe how you keep the announced state and the visible state from diverging in a component library, and what you check in the accessibility tree to confirm it.
Frame stale ARIA state as a class of defect rather than a one-off bug, and be ready to say how a component API and its tests should make the disclosure contract impossible to get wrong across many teams.
## The pattern A disclosure is the simplest interactive widget on the web: a trigger that shows and hides a region. Accordions, "show more" toggles, navigation submenus and the button that opens a dropdown are all disclosures. The markup is small: ```html <button aria-expanded="false" aria-controls="shipping">Shipping details</button> <div id="shipping" hidden> <p>Ships in 2-3 business days.</p> </div> ``` A sighted user sees the panel appear and the chevron rotate. Someone using a screen reader gets none of that; the only channel available is what the accessibility tree says about the button. `aria-expanded` is that channel. ## What it communicates The state answers one question: is the content this control governs currently displayed? Screen readers fold it into the button's announcement — typically something like "Shipping details, button, collapsed". It also tells the user, before they act, that this button *is* a toggle rather than a link or a submit. That second job is why the third possible condition matters. ## Three conditions, not two - `aria-expanded="true"` — the controlled content is showing. - `aria-expanded="false"` — the control is expandable and the content is hidden. - **attribute absent** — the default, `undefined` in the specification's terms: this control is not expandable. Nothing is announced. That last case is the one people get wrong. Removing the attribute when the panel closes does not mean "collapsed", it means "not a disclosure", and the user loses the affordance entirely. Set it to the string `"false"` instead. Equally, these are string values in an attribute, not HTML boolean attributes: the mere presence of `aria-expanded` does not mean true, and `aria-expanded="false"` is genuinely false. ## It belongs on the control The most common structural mistake is putting the state on the region: ```html <!-- wrong: the state is on the thing being revealed --> <button>Shipping details</button> <div id="shipping" aria-expanded="false" hidden>...</div> ``` Nothing announces that. The user reaches the button, not the hidden panel, and the button is where a decision gets made. Put the state on whatever the user activates. Where the trigger and the region are far apart in the DOM, `aria-controls` on the button can point at the region's `id`, though support for that hint varies across screen readers, so treat it as supplementary rather than load-bearing. `aria-expanded` is not universal across roles — it is supported on roles such as `button`, `link`, `combobox`, `tab`, `treeitem`, `row`, `menuitem` and `gridcell`. It is meaningless on a plain container, and it should never be added to a button that merely navigates or submits, because that announces an open/close affordance that does not exist. ## The synchronisation obligation ARIA is inert. Setting `aria-expanded="true"` reveals nothing and hides nothing; showing and hiding is done by the `hidden` attribute, by CSS, or by removing the node. That means the visible state and the announced state are two separate facts that your code must change together, in the same handler, every time. When they drift, the accessibility tree confidently reports a lie, and a lie is harder to work around than silence — the user has no reason to distrust it. If your toggle logic has one place that changes visibility, put the attribute update in that same place rather than scattering it across call sites. One more nuance: when a panel is hidden with the `hidden` attribute or `display: none`, its contents are removed from the accessibility tree, which is correct — a closed panel's contents should not be reachable. Hiding it visually with something that leaves it in the tree, so screen reader users can still tab into a "closed" panel, is the mirror-image bug and one interviewers like to probe. ## What good looks like A native `<button>` for the trigger, so keyboard activation, focusability and the button role come free; `aria-expanded` on that button, initialised in the HTML rather than only by script; the panel hidden in a way that removes it from the tree; and exactly one code path that flips both visibility and the attribute together.
- Why is removing aria-expanded when the panel closes not equivalent to setting it to "false"?Because the absent state means `undefined` — this control is not expandable. Screen readers then announce a plain button and the user never learns it toggles anything. `aria-expanded="false"` says something different and more useful: this is a disclosure, and it is currently closed. Keep the attribute present at all times and only change its value.
- Does setting aria-expanded="true" make the panel appear?No. ARIA never changes behaviour or rendering; it only changes what the accessibility tree reports. The panel appears because you removed `hidden`, changed CSS, or inserted the node. The attribute is a parallel description that your code must keep in step with the real visibility, which is exactly why stale values are such a common defect.
- Should a button that submits a form or navigates to another page carry aria-expanded?No. Adding it announces an expand/collapse affordance the control does not have, so a user who activates it expecting content to appear in place gets a page change instead. `aria-expanded` is only for controls that show and hide content, and it is supported on a defined set of roles such as button, link, combobox, tab and treeitem.
saying these in an interview costs you the question
- Putting aria-expanded on the panel instead of the trigger
- Removing the attribute rather than setting it to "false"
- Assuming the attribute's presence alone means expanded
- Thinking aria-expanded="true" actually reveals the content
- Adding aria-expanded to buttons that merely navigate