Lighthouse audit aria-hidden-focus · Accessibility

aria-hidden elements contain focusable descendents: how to fix it

Quick answer: This audit fails when an element marked aria-hidden="true" still contains a link, button or input that can receive keyboard focus. Usually it is a closed mobile drawer or an off-screen carousel slide. Fix it by adding the inert attribute to the hidden container, or by setting tabindex="-1" on every focusable child.
View raw .md for LLMs / your notes

This Lighthouse audit fails when an element marked aria-hidden="true" still contains something a keyboard can reach: a link, a button, a form field, or anything with tabindex="0". aria-hidden removes content from the accessibility tree. It does not remove it from the tab order. So a keyboard user tabs onto a control they cannot see, and a screen reader user lands on a control that is announced as nothing.

TL;DR

What does the aria-hidden-focus audit check?

Lighthouse runs the axe-core rule aria-hidden-focus against the rendered DOM. Deque words the same rule "ARIA hidden element must not be focusable or contain focusable elements", so you will see both phrasings in search results and in axe DevTools. They are the same check.

For every element with aria-hidden="true", axe looks for descendants that can receive keyboard focus:

A descendant is fine if it has tabindex="-1", is a disabled form control, is hidden with display: none or visibility: hidden, or sits inside an inert subtree. Position does not matter: a slide moved off-screen with transform is still focusable, and still fails.

The failing title reads [aria-hidden="true"] elements contain focusable descendents. The passing title is [aria-hidden="true"] elements do not contain focusable descendents. "Descendents" is Lighthouse's own spelling, so search for it that way.

What does "Focusable content should have tabindex="-1" or be removed from the DOM" mean?

When you expand a failing element in the Lighthouse report, axe-core prints one of these blocks under it:

Fix all of the following:
  Focusable content should be disabled or be removed from the DOM
  Focusable content should have tabindex="-1" or be removed from the DOM

The offender shown is the aria-hidden="true" container, not the focusable child, which is why the snippet often looks harmless: <div class="mobile-drawer" aria-hidden="true">. The problem is inside it.

"Fix all of the following" is literal, unlike the "fix any" block under button-name and link-name. Every focusable child must be dealt with. The first line covers form controls (disabled takes them out of focus). The second line covers links and buttons (tabindex="-1"). "Removed from the DOM" means do not render the hidden content at all until it is shown. Adding inert to the container satisfies both lines in one attribute, because axe-core has treated inert content as not focusable since version 4.7.

Why does aria-hidden with focusable content matter?

How do I fix "[aria-hidden="true"] elements contain focusable descendents"?

Choose by what the hidden content is.

1. Content hidden from everyone (closed drawer, closed modal, collapsed menu). Do not use aria-hidden alone. Hide it for all users, which also removes it from the tab order:

<!-- FAILS: invisible, but its links are still in the tab order -->
<nav class="mobile-drawer" aria-hidden="true">
  <a href="/shop">Shop</a>
  <a href="/about">About</a>
</nav>

<!-- FIX: hidden from everyone while closed -->
<nav class="mobile-drawer" id="drawer" hidden>
  <a href="/shop">Shop</a>
  <a href="/about">About</a>
</nav>

If the drawer needs a slide-in animation, display: none breaks it. Use visibility: hidden on the closed state instead (it animates when paired with a transition on transform), or add inert while closed.

2. Content that is only visually off-screen (carousel slides). Make inactive slides inert, and toggle it as slides change:

<!-- FAILS: inactive slide hidden from screen readers, links still focusable -->
<div class="slide" aria-hidden="true" style="transform: translateX(200%)">
  <a href="/products/linen-shirt">Linen shirt</a>
</div>

<!-- FIX: inert removes focus and hides it from assistive tech in one attribute -->
<div class="slide" inert style="transform: translateX(200%)">
  <a href="/products/linen-shirt">Linen shirt</a>
</div>
// On slide change
slides.forEach((slide, i) => {
  slide.inert = i !== activeIndex;
});

inert is supported in all current browsers (Chrome, Edge, Firefox and Safari since 2023), and it also blocks mouse clicks and text selection on the hidden slide, which is what you want.

3. When you cannot add inert (a third-party widget you only control through callbacks), take each focusable child out of the tab order while hidden, and restore it when shown:

const FOCUSABLE = 'a[href], button, input, select, textarea, summary, iframe, [tabindex]';

function setHidden(container, hidden) {
  container.setAttribute('aria-hidden', String(hidden));
  container.querySelectorAll(FOCUSABLE).forEach((el) => {
    if (hidden) {
      if (el.hasAttribute('tabindex')) el.dataset.prevTabindex = el.getAttribute('tabindex');
      el.setAttribute('tabindex', '-1');
    } else if ('prevTabindex' in el.dataset) {
      el.setAttribute('tabindex', el.dataset.prevTabindex);
      delete el.dataset.prevTabindex;
    } else {
      el.removeAttribute('tabindex');
    }
  });
}

Saving the previous value matters: a blanket removeAttribute('tabindex') on show would break any child that had a deliberate tabindex.

What are the most common causes on a real site?

In a scan of 269 production storefronts, aria-hidden-focus failed on 21.6% of them (58 sites). Re-running live on failing stores, the offenders were the same two shapes:

How do I fix this in React, Next.js, WordPress, or Shopify?

React / Next.js

React 19 accepts inert as a boolean prop. Older React versions warn on it, so set it as a string or through a ref:

// React 19
<div className="slide" inert={!isActive}>...</div>

// React 18 and earlier
<div className="slide" inert={!isActive ? '' : undefined}>...</div>

For drawers and dialogs, prefer the native <dialog> element or a library that manages focus (Radix, React Aria, Headless UI). They do not leave closed content focusable. Catch regressions with @axe-core/playwright in end-to-end tests: the failing state only exists after the component renders, so static linting misses it.

WordPress

Shopify

The failing stores we re-ran used theme sliders built on libraries such as Flickity. Whatever the library, toggle inert when the selected slide changes. For Flickity, use its change event:

flkty.on('change', () => {
  flkty.cells.forEach((cell, i) => {
    cell.element.inert = i !== flkty.selectedIndex;
  });
});

Check theme sections and app-injected sections separately: the offender snippet's class names usually show which one it is. If the offender comes from an app, the fix belongs to the app developer, so report it to them with the Lighthouse snippet.

What aria-hidden-focus pitfalls should I avoid?

How do I verify the fix?

  1. Re-run Lighthouse. The aria-hidden-focus audit should pass and move into "Passed audits".
  2. Press Tab from the top of the page with every drawer closed and every carousel at rest. Focus must never disappear or land on something off-screen. Then open the drawer and advance the carousel, and tab again.
  3. In Chrome DevTools, select a hidden slide and check the Accessibility pane. An inert or hidden subtree should show as ignored, and its children should not be in the tab order.
  4. Check at mobile width as well as desktop. Off-canvas drawers usually only exist at small breakpoints, so a desktop-only check misses them.
  5. Add axe DevTools or an axe check in your end-to-end tests, run after interacting with the slider, to catch regressions before they ship.

Audit your URL at https://lighthouse-md.com.

Audit your page now

Paste your URL, get scores plus a CLAUDE.md plan for Claude Code.

Run audit →
Add us as a preferred source on Google

One click, and Google shows more of lighthouse-md.com in your search results.