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

// 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/'));

Steps

  1. Step 1.

    Start the demo server in its own terminal.

    node aria-demo.mjs
    
    http://localhost:8843/
  2. 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/ 7
    
    AX 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: uninteresting

    Stops 2, 3 and 4 are the defect. Chrome gives each of them role none and 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.

  3. Step 3.

    Widen the check from the tab sequence to every focusable candidate, so inert and display: none are 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 tree

    Rows five and six are the contrast worth keeping. inert and display: none remove the element from the tree and from the tab order together, so the two facts stay consistent. aria-hidden changes one of them and leaves the other alone.

  4. Step 4.

    Run the route that sets aria-hidden on 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/blocked
    
    focus             : 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.

  5. 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 tree

    Chrome refused to apply aria-hidden="true" to #panel because a descendant held focus, so #save kept its role and its name. The same markup with focus elsewhere reports ignored: ariaHiddenSubtree.

  6. 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/HTTP
    
    a "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 tree

    Most of the 584 rows are collapsed menu contents, which report focusable=false and 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

Sign: An audit looks for the element in getFullAXTree by name or by role, does not find it, and reports it as absent from the tree.Cause: Chrome keeps an aria-hidden element in the tree with ignored set to true, role none and no name at all. Searching by name matches nothing, which looks the same as an element that was never there, and hides the difference between aria-hidden and display: none. Join the two views on backendDOMNodeId and read ignoredReasons.
Sign: The same page passes and fails the check on different runs, with no code change between them.Cause: Chrome does not apply aria-hidden to a subtree while a descendant holds focus. In step 4 the attribute was set on #panel and the tree still reported button Save. Whether an element shows as ignored depends on where focus was when the tree was computed, so give the audit a fixed starting focus and record it.
Sign: A scan for aria-hidden reports dozens of hits and almost none of them are defects.Cause: The attribute on a decorative svg inside a button is correct, and the demo has one: the icon is hidden so the button is announced as Print and not as Print plus a graphic. The attribute alone is not the signal. The signal is the attribute together with the element still taking focus.

What to check next

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.

intermediate10 minpublished updated Maks Verny