Testing aria-controls
aria-controls identifies the element or elements that a given element controls. It's set on the controlling element and points, by ID, to whatever it affects. It doesn't move focus and doesn't trigger anything on its own, it's a relationship, not a behaviour.
Support for actually announcing that relationship is patchy across screen reader and browser combinations, and a chunk of the accessibility community treats it as effectively decorative. The examples below cover the common working patterns first, then a set of deliberately broken cases, so we can test what's real rather than repeat what's assumed.
Example 1: show/hide toggle
A single button controlling a single panel of content that appears and disappears. Focus moves to the new content's heading once it's revealed. The panel already exists in the DOM at page load, just hidden with the hidden attribute, so aria-controls has a valid target from the very first render.
Expected result: activating the button should announce the new expanded state, move focus to the heading inside the revealed panel, and screen reader users navigating by relationship (if supported) should be able to identify that the button controls this specific panel.
HTML markup
<!-- Button -->
<button
aria-controls="ex1-panel"
aria-expanded="false"
>
Show details
</button>
<!-- Panel -->
<div id="ex1-panel" hidden>
...
</div>
Example 2: controlled element inserted after page load
Visually, this looks identical to Example 1, a button reveals a panel. The difference is entirely in the DOM and its timing, not in anything a sighted user can see:
- In Example 1, the panel exists in the DOM from page load, just hidden.
aria-controlsalways has a valid target to point to. - Here, the panel doesn't exist in the DOM at all until this button is activated for the first time.
aria-controlspoints at an ID that doesn't exist yet, and only becomes valid the moment the script inserts it.
This is a real-world failure mode distinct from a typo (see the broken examples further down), the reference isn't wrong, it's just not there yet.
Expected result: on first activation, aria-controls points at an ID that doesn't exist yet. Worth checking whether this behaves differently to Example 1, where the target was always present, and differently again to the permanently broken reference in Example 6 further down, since here the reference becomes valid after interaction rather than staying broken.
HTML markup
<!-- before activation, ID not yet in the DOM -->
<button
aria-controls="ex2-panel"
aria-expanded="false"
>
Reveal dynamically inserted panel
</button>
<!-- #ex2-panel is only created and inserted by JS on click -->
Example 3: tabs
Each tab controls a separate, always-present-in-DOM panel, only one of which is visible at a time. This is a full, accessible tabs pattern, not just the aria-controls relationship, arrow keys move between tabs and switch panels, Home/End jump to the first/last tab.
Wombats are sturdy, burrowing marsupials native to Australia.
Emus are large, flightless birds that can run at high speeds.
Bandicoots are small, nocturnal marsupials with pointed snouts.
Expected result: each tab should be identifiable as controlling its matching panel. Arrow Left/Right move focus between tabs and switch the visible panel immediately. Home/End jump to the first/last tab. Since role="tabpanel" is a well-established pattern, this is a good baseline case, if aria-controls fails to announce anywhere, it's likely to fail here too.
HTML markup
<!-- tab container -->
<div role="tablist" aria-label="Example tabs">
<!-- buttons -->
<button
role="tab"
aria-selected="true"
aria-controls="ex3-panel-1"
tabindex="0"
>
Wombats
</button>
<button
role="tab"
aria-selected="false"
aria-controls="ex3-panel-2"
tabindex="-1"
>
Emus
</button>
</div>
<!-- panels -->
<div
id="ex3-panel-1"
role="tabpanel"
aria-labelledby="ex3-tab-1"
>
Wombats are sturdy...
</div>
<div
id="ex3-panel-2"
role="tabpanel"
aria-labelledby="ex3-tab-2"
hidden
>
Emus are large...
</div>
Example 4: filter controlling an always-visible results list
Unlike the previous examples, the controlled element here is never hidden, only its contents change. This tests whether aria-controls is announced at all when there's no expand/collapse state riding alongside it.
- Quarterly report
- Budget proposal
- 2023 planning notes
- Old meeting minutes
Expected result: this is the weakest signal case by design, no aria-expanded, no hidden state, just a bare relationship plus a content change. Worth noting whether any AT announces this relationship at all, and whether the content change itself is picked up without an explicit live region.
HTML markup
<!-- Label -->
<label class="label" for="ex4-filter">Filter results</label>
<!-- Select -->
<select id="ex4-filter" aria-controls="ex4-results">
<option value="all">All</option>
<option value="active">Active</option>
<option value="archived">Archived</option>
</select>
<!-- Results-->
<ul id="ex4-results">
<li data-status="active">Quarterly report</li>
<!-- remaining items follow the same pattern -->
</ul>
Example 5: one control, multiple targets
aria-controls accepts a space-separated list of IDs. This is valid per spec but far less common in the wild, worth testing separately since a multi-target relationship may not behave the same as a single-target one.
Koalas are tree-dwelling marsupials that mainly eat eucalyptus leaves.
Platypuses are unusual egg-laying mammals with broad, duck-like bills.
Expected result: confirm whether AT announces a relationship to two elements, one element, or none at all, this is untested territory for most testers.
HTML markup
<!-- Button -->
<button
aria-controls="ex5-panel-a ex5-panel-b"
aria-expanded="false"
>
Toggle both panels
</button>
<!-- Panels -->
<div id="ex5-panel-a" hidden>
Koalas are tree-dwelling...
</div>
<div id="ex5-panel-b" hidden>
Platypuses are unusual...
</div>
Broken examples from here on. These are deliberately incorrect. They exist to test failure behaviour, not to be copied. Where possible, the visible show/hide behaviour still works correctly, so the demo remains usable, only the aria-controls relationship itself is broken, which is the specific thing being tested.
Broken example 6: ID does not resolve
aria-controls references an ID that has never existed on the page. The button still correctly shows and hides its panel, only the aria-controls attribute itself is wrong.
This panel's real ID is ex6-panel, but aria-controls points at ex6-panle-typo.
Expected result: confirm what, if anything, is announced, silence, an error, or no different to Example 1.
HTML markup
<!-- Button: Value does not match ID -->
<button
aria-controls="ex6-panle-typo"
aria-expanded="false"
>
Show details
</button>
<!-- Panel -->
<div id="ex6-panel" hidden>
...
</div>
Broken example 7: empty value
aria-controls is present but has no value at all, no ID reference of any kind. Distinct from Example 6, this isn't a broken reference, it's not a reference at all. The button still works visually, using a direct reference in the script rather than relying on the empty attribute.
This panel is shown correctly, but aria-controls itself is empty and points at nothing.
Expected result: exactly the kind of markup error an automated checker is built to catch, an empty, non-referencing value. Worth confirming whether AT treats this the same as Example 6's broken reference, the same as the attribute being absent entirely, or something else.
HTML markup
<!-- Button: aria-controls has no value -->
<button
aria-controls=""
aria-expanded="false"
>
Show details
</button>
<!-- Panel -->
<div id="ex7-panel" hidden>
...
</div>
Note for testing: given the reputation aria-controls has for patchy support, this page is a good candidate for the full AT/browser matrix, Mac VoiceOver, Windows JAWS, Windows NVDA, iOS VoiceOver, Android TalkBack, across the browsers you'd normally pair with each, rather than a partial spot check.