How to check aria hidden elements are not focusable
Collect the elements that can take focus, collect the accessibility tree Chrome computed, and report every element in the first set that the tree marks ignored or leaves out. The defect is the intersection of two facts, not a property on one node. Three rows on the demo page below.
Why check this
Run this after any change that hides part of a page: a collapsed drawer, an off-canvas menu, a carousel slide, the content behind an open dialog. The defect it finds is a tab stop that exists for the keyboard and not for the accessibility tree. A screen reader user presses Tab, focus moves, and there is no role and no name to announce, so the software says nothing useful about where the cursor now is.
The two halves have to be measured separately. Focusability comes from the element and its ancestors, and aria-hidden does not change it. Presence in the tree comes from Chrome, and it survives aria-hidden in the case covered in step 4. Joining them is what makes the check work. A scan for the attribute on its own reports mostly false hits, because decorative icons inside buttons carry it on purpose.
Prerequisites
- Node 22 and Chrome on one machine, plus
npm i puppeteer-core. See the puppeteer API. - A local page with one element of each kind: an
aria-hiddenlink, anaria-hiddencontainer, aninertcontainer, adisplay: nonebutton, a decorativesvginside a button. Save it asaria-demo.mjs.
// aria-demo.mjs - one page of hidden elements, three of them focusable. node aria-demo.mjs
import { createServer } from 'node:http';
const body = `<h1>Dashboard</h1>
<a href="/orders">Orders</a>
<a href="/docs" aria-hidden="true">Docs</a>
<div id="drawer" aria-hidden="true">
<a href="/archive">Archived orders</a>
<button id="restore">Restore</button>
</div>
<div inert><button id="inert-action">Inert action</button></div>
<button id="never" style="display:none">Never shown</button>
<button id="print"><svg aria-hidden="true" width="16" height="16"><rect width="16" height="16"/></svg>Print</button>
<div id="panel"><button id="save">Save</button></div>`;
const page = (extra) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>aria-hidden demo</title>
<style>body{font:16px system-ui;margin:2rem}#drawer{opacity:.4}</style></head><body>
${body}
${extra}
</body></html>`;
const routes = {
'/': page(''),
// Focus first, hide the ancestor after: the pattern an audit is asked to catch.
'/blocked': page(`<script>
document.getElementById('save').focus();
document.getElementById('panel').setAttribute('aria-hidden', 'true');
<\/script>`),
};
createServer((req, res) => {
res.setHeader('content-type', 'text/html; charset=utf-8');
res.end(routes[req.url] ?? routes['/']);
}).listen(8843, () => console.log('http://localhost:8843/'));
- The figures are one capture on one machine, Chrome 152.0.7977.76. The accessibility tree is computed by the browser, so another engine reports different node counts.
- aria-hidden on MDN and WCAG 4.1.2 Name, Role, Value, Level A, are what this maps to.
Steps
- Step 1.
Start the demo server in its own terminal.
node aria-demo.mjshttp://localhost:8843/ - Step 2.
Walk the tab stops and print the accessibility node for each one. The focused element is resolved through open shadow roots, and matched to the tree by
backendDOMNodeId, the only field the two views share.// tab-vs-tree.mjs - node tab-vs-tree.mjs <url> <presses> import { launch } from 'puppeteer-core'; const [url, n = '7'] = process.argv.slice(2); const focused = () => { let e = document.activeElement; while (e?.shadowRoot?.activeElement) e = e.shadowRoot.activeElement; return e; }; const label = (e) => e.tagName.toLowerCase() + (e.id ? '#' + e.id : '') + ' "' + (e.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 14) + '"'; const browser = await launch({ channel: 'chrome', headless: true }); const page = await browser.newPage(); const cdp = await page.createCDPSession(); await cdp.send('DOM.enable'); await page.goto(url, { waitUntil: 'networkidle2' }); await cdp.send('DOM.getDocument', { depth: -1 }); const { nodes } = await cdp.send('Accessibility.getFullAXTree'); const ax = new Map(nodes.filter((x) => x.backendDOMNodeId).map((x) => [x.backendDOMNodeId, x])); console.log(`AX tree: ${nodes.length} nodes, ${nodes.filter((x) => x.ignored).length} of them ignored`); for (let i = 1; i <= Number(n); i++) { await page.keyboard.press('Tab'); const { result } = await cdp.send('Runtime.evaluate', { expression: `(${focused})()` }); const { node } = await cdp.send('DOM.describeNode', { objectId: result.objectId }); const { result: lbl } = await cdp.send('Runtime.evaluate', { expression: `(${label})((${focused})())`, returnByValue: true }); const a = ax.get(node.backendNodeId); console.log(`${i}. ${lbl.value.padEnd(30)} ` + (!a ? 'absent from the tree' : a.ignored ? 'ignored: ' + a.ignoredReasons.map((r) => r.name).join(',') : `${a.role.value} "${a.name.value}"`)); } await browser.close();node tab-vs-tree.mjs http://localhost:8843/ 7AX tree: 24 nodes, 8 of them ignored 1. a "Orders" link "Orders" 2. a "Docs" ignored: ariaHiddenElement 3. a "Archived order" ignored: ariaHiddenSubtree 4. button#restore "Restore" ignored: ariaHiddenSubtree 5. button#print "Print" button "Print" 6. button#save "Save" button "Save" 7. body "Dashboard Orde" ignored: uninterestingStops 2, 3 and 4 are the defect. Chrome gives each of them role
noneand no name, and two different reasons: the attribute is on the element itself at stop 2, and on an ancestor at stops 3 and 4. - Step 3.
Widen the check from the tab sequence to every focusable candidate, so
inertanddisplay: noneare covered as well. The script focuses each element and asks whether focus arrived.// hidden-focusable.mjs - node hidden-focusable.mjs <url> import { launch } from 'puppeteer-core'; const SEL = 'a[href],button,input,select,textarea,summary,iframe,[tabindex]:not([tabindex="-1"])'; const probe = function () { this.focus(); return this.tagName.toLowerCase() + (this.id ? '#' + this.id : '') + ' "' + (this.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 15) + '"' + '|' + (document.activeElement === this); }; const browser = await launch({ channel: 'chrome', headless: true }); const page = await browser.newPage(); const cdp = await page.createCDPSession(); await cdp.send('DOM.enable'); await page.goto(process.argv[2], { waitUntil: 'networkidle2' }); const { nodes } = await cdp.send('Accessibility.getFullAXTree'); const ax = new Map(nodes.filter((n) => n.backendDOMNodeId).map((n) => [n.backendDOMNodeId, n])); const { root } = await cdp.send('DOM.getDocument', { depth: -1 }); const { nodeIds } = await cdp.send('DOM.querySelectorAll', { nodeId: root.nodeId, selector: SEL }); let defects = 0; for (const nodeId of nodeIds) { const { node } = await cdp.send('DOM.describeNode', { nodeId }); const { object } = await cdp.send('DOM.resolveNode', { nodeId }); const { result } = await cdp.send('Runtime.callFunctionOn', { objectId: object.objectId, functionDeclaration: probe.toString(), returnByValue: true }); const [text, got] = result.value.split('|'); const a = ax.get(node.backendNodeId); if (got === 'true' && (!a || a.ignored)) defects += 1; console.log(`${text.padEnd(34)} focusable=${got.padEnd(5)} ` + (!a ? 'absent from the tree' : a.ignored ? 'ignored: ' + a.ignoredReasons.map((r) => r.name).join(',') : `${a.role.value} "${a.name.value}"`)); } console.log(`${nodeIds.length} candidates, ${defects} focusable and hidden from the tree`); await browser.close();node hidden-focusable.mjs http://localhost:8843/a "Orders" focusable=true link "Orders" a "Docs" focusable=true ignored: ariaHiddenElement a "Archived orders" focusable=true ignored: ariaHiddenSubtree button#restore "Restore" focusable=true ignored: ariaHiddenSubtree button#inert-action "Inert action" focusable=false absent from the tree button#never "Never shown" focusable=false absent from the tree button#print "Print" focusable=true button "Print" button#save "Save" focusable=true button "Save" 8 candidates, 3 focusable and hidden from the treeRows five and six are the contrast worth keeping.
inertanddisplay: noneremove the element from the tree and from the tab order together, so the two facts stay consistent.aria-hiddenchanges one of them and leaves the other alone. - Step 4.
Run the route that sets
aria-hiddenon an ancestor of the focused element, and collect whatever Chrome reports.// blocked-warning.mjs - node blocked-warning.mjs <url> import { launch } from 'puppeteer-core'; const browser = await launch({ channel: 'chrome', headless: true }); const page = await browser.newPage(); const cdp = await page.createCDPSession(); const seen = []; cdp.on('Audits.issueAdded', (e) => seen.push('issue ' + e.issue.code)); page.on('console', (m) => seen.push(`console ${m.type()}: ${m.text()}`)); await cdp.send('Audits.enable'); await page.goto(process.argv[2], { waitUntil: 'networkidle2' }); await new Promise((r) => setTimeout(r, 600)); console.log('focus : ' + await page.evaluate(() => document.activeElement.id || '(body)')); console.log('its ancestor : ' + await page.evaluate(() => document.activeElement.closest('[aria-hidden="true"]')?.id ?? '(none)')); console.log('messages captured : ' + (seen.join(' / ') || '(none)')); await browser.close();node blocked-warning.mjs http://localhost:8843/blockedfocus : save its ancestor : panel messages captured : (none)The attribute is on the page and Chrome said nothing about it, over the console and over the Audits domain.
- Step 5.
Read the tree for that same route and compare it with step 3.
node hidden-focusable.mjs http://localhost:8843/blocked… button#save "Save" focusable=true button "Save" 8 candidates, 3 focusable and hidden from the treeChrome refused to apply
aria-hidden="true"to#panelbecause a descendant held focus, so#savekept its role and its name. The same markup with focus elsewhere reportsignored: ariaHiddenSubtree. - Step 6.
Run the audit against a page you did not build, to see the shape of a clean result.
node hidden-focusable.mjs https://developer.mozilla.org/en-US/docs/Web/HTTPa "Skip to main co" focusable=true link "Skip to main content" a "Skip to search" focusable=true link "Skip to search" a "MDN" focusable=true link "MDN" button "" focusable=true button "Toggle navigation" button "HTML" focusable=false ignored: notRendered a "HTML: Markup la" focusable=false absent from the tree … 584 candidates, 0 focusable and hidden from the treeMost of the 584 rows are collapsed menu contents, which report
focusable=falseand no node. Both halves agree, so none of them counts.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| focusable=true with ignored: ariaHiddenElement | The control itself carries the attribute | Remove it, or make the control unreachable with inert |
| focusable=true with ignored: ariaHiddenSubtree | An ancestor carries the attribute | Put inert on that ancestor instead, which hides and blocks focus together |
| focusable=true with absent from the tree | Focusable and not exposed at all | Read the node in DevTools. A custom element or a shadow root is the usual cause |
| focusable=false with absent from the tree | inert, display: none or hidden | Nothing. The two facts agree |
| focusable=false with ignored: notRendered | Collapsed or off-screen content | Nothing while it is closed. Open it and run again |
Common mistakes
What to check next
- How to check accessibility tree: how to read one node in detail once this check names it.
- How to test keyboard navigation on a website: produces the focusable half of the intersection for a whole page.
- How to test a focus trap in a modal: the dialog case, where
aria-hiddenon the background is the wrong tool andinertis the right one. - How to check aria labels: the other way a tab stop ends up with nothing to announce.
FAQ
What is the difference between hidden and aria-hidden?
The hidden attribute removes the element from the rendered page, from the tree and from the tab order. aria-hidden="true" removes it from the tree only. It stays visible, clickable and focusable, which is the whole reason this check exists.
What does a blocked aria-hidden warning mean?
Chrome declines to apply aria-hidden to an element that contains the focused element, so the subtree stays in the tree. Step 4 reproduces that state. On Chrome 152 no message reached this capture, with the Audits domain enabled and in both a headless and a headed browser, so an automated check cannot depend on the warning.
Does aria-hidden remove an element from the tab order?
No. Every one of the three defect rows in step 3 reports focusable=true. To hide something from everyone, use inert on the container, or display: none, or the hidden attribute, all three of which the demo shows removing both facts at once.
Is aria-hidden on an svg inside a button a problem?
No, that is the intended use. The svg is not focusable, so the intersection is empty and the button is announced by its text. The demo carries one so the check has a correct case.
Verified
Verified by Maks VernyChrome 152.0.7977.76node 22.23.2puppeteer-core 25.10.0
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate10 minpublished updated Maks Verny