# aria-hidden content that keyboard focus still reaches

Source: https://easeweb.dev/learn/aria-hidden-focus
Topics: Page structure > ARIA attributes are valid and never hide focusable content (WCAG 4.1.2)
The fix: Hide the closed menu from everyone at once, not from screen readers alone. Either hide it with CSS, visibility: hidden or the hidden attribute, while it is out of view, or make it inert, which takes it out of the tab order, the pointer and the accessibility tree. Keeping aria-hidden and giving every link inside tabindex="-1" also passes, but only while no control inside is missed.
Test with: keyboard, screen reader
References: https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html https://www.w3.org/WAI/standards-guidelines/act/rules/6cfa84/ https://www.w3.org/TR/using-aria/#4thrule https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-hidden https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/inert https://dequeuniversity.com/rules/axe/4.10/aria-hidden-focus

This is a recreated teaching example, not a finding about a named client. The code is a starting pattern: test it in your own product. No completed assistive-technology test is claimed here.

## What happens

A homeware shop has a Menu button in its header. It opens a menu that slides in from the left with four links: Shop, Journal, About and Contact. When the menu is closed, it waits just past the left edge of the screen. Press Tab from the Menu button and the focus ring disappears. Three more presses and it is still gone. On the fifth it comes back, on the first link in the page. Those four stops were the closed menu's links, out of view. A screen reader on the same four stops often says nothing at all. Some combinations of browser and screen reader read part of the link, so two people on the same page hear different things.

## Why it fails

The closed menu is moved off screen with `transform` and given `aria-hidden="true"`, so that screen readers skip it. `aria-hidden` does only that: it removes the menu from the accessibility tree. It does not take anything out of the tab order or stop a click. So focus lands on links whose name and role are not exposed to assistive technology, which fails 4.1.2 Name, Role, Value. WAI-ARIA forbids `aria-hidden` on anything that can take focus, for this reason. Because the menu is also out of view, the focus ring on those stops can't be seen, and the order runs through controls that are not there, so the same stops break focus visibility and focus order as well. The fixes below take the links out of the order while the menu is closed, which repairs all three.

## Who is affected

The same failure turns up wherever `aria-hidden` hides something that focus can still reach: carousel slides out of view, tab panels behind the active tab, the page behind a custom dialog, and a repeated photo link in a product card. Mouse users never meet it, because nothing off screen can be clicked.

## What automation and AI miss

axe's `aria-hidden-focus` finds this in the state the page loads in: a closed menu with links inside it is reported. A scan doesn't open and close the menu, so content that only gets `aria-hidden` after an interaction is missed. When a modal is open, or a focus handler is attached, axe asks for a review instead of reporting a failure. The quick fix a scanner invites is to delete `aria-hidden`. The scanner then goes quiet, but the four invisible stops stay, now read out as links nobody can see. Another quick fix adds `tabindex="-1"` to the links in today's template, which holds until someone adds a link to the menu.

## Before: a closed menu hidden from screen readers only

```html
<button type="button" class="menu-button" aria-expanded="false" aria-controls="menu">Menu</button>
<nav id="menu" class="drawer" aria-label="Main" aria-hidden="true">
  <a href="/shop">Shop</a>
  <a href="/journal">Journal</a>
  <a href="/about">About</a>
  <a href="/contact">Contact</a>
</nav>
```

```css
.drawer {
  position: fixed;
  inset: 0 auto 0 0;
  width: 280px;
  transform: translateX(-100%);
  transition: transform 0.3s;
}
.drawer.is-open {
  transform: none;
}
```

```js
const button = document.querySelector('.menu-button');
const menu = document.getElementById('menu');
button.addEventListener('click', () => {
  const open = button.getAttribute('aria-expanded') !== 'true';
  button.setAttribute('aria-expanded', String(open));
  menu.classList.toggle('is-open', open);
  menu.setAttribute('aria-hidden', String(!open));
});
```

`transform` hides the closed menu from sight and `aria-hidden` hides it from screen readers, but nothing hides it from the keyboard. Every link stays in the tab order, out of view and without a name a screen reader will read.

## After: the closed menu hidden with CSS

```html
<button type="button" class="menu-button" aria-expanded="false" aria-controls="menu">Menu</button>
<nav id="menu" class="drawer" aria-label="Main">
  <a href="/shop">Shop</a>
  <a href="/journal">Journal</a>
  <a href="/about">About</a>
  <a href="/contact">Contact</a>
</nav>
```

```css
.drawer {
  position: fixed;
  inset: 0 auto 0 0;
  width: 280px;
  transform: translateX(-100%);
  visibility: hidden;
  /* Hide it once the slide out has finished. */
  transition: transform 0.3s, visibility 0s 0.3s;
}
.drawer.is-open {
  transform: none;
  visibility: visible;
  transition: transform 0.3s;
}
```

```js
const button = document.querySelector('.menu-button');
const menu = document.getElementById('menu');
button.addEventListener('click', () => {
  const open = button.getAttribute('aria-expanded') !== 'true';
  button.setAttribute('aria-expanded', String(open));
  menu.classList.toggle('is-open', open);
});
```

`visibility: hidden` hides the closed menu from sight, the keyboard, the pointer and screen readers at once, so `aria-hidden` is no longer needed. The delay on `visibility` lets the menu slide out before it is hidden, and the open state shows it straight away. When the menu doesn't animate, the `hidden` attribute does the same job: `menu.hidden = !open`.

## After: the closed menu made inert

```html
<button type="button" class="menu-button" aria-expanded="false" aria-controls="menu">Menu</button>
<nav id="menu" class="drawer" aria-label="Main" inert>
  <a href="/shop">Shop</a>
  <a href="/journal">Journal</a>
  <a href="/about">About</a>
  <a href="/contact">Contact</a>
</nav>
```

```js
const button = document.querySelector('.menu-button');
const menu = document.getElementById('menu');
button.addEventListener('click', () => {
  const open = button.getAttribute('aria-expanded') !== 'true';
  button.setAttribute('aria-expanded', String(open));
  menu.classList.toggle('is-open', open);
  menu.inert = !open;
});
```

The CSS stays as it was in the Before. `inert` takes the closed menu and everything in it out of the tab order, the accessibility tree and the reach of the pointer, while it stays drawn, so it covers what `aria-hidden` did and what it left out. Every current browser supports it.

## After: aria-hidden kept, and every link taken out of the tab order

```html
<button type="button" class="menu-button" aria-expanded="false" aria-controls="menu">Menu</button>
<nav id="menu" class="drawer" aria-label="Main" aria-hidden="true">
  <a href="/shop" tabindex="-1">Shop</a>
  <a href="/journal" tabindex="-1">Journal</a>
  <a href="/about" tabindex="-1">About</a>
  <a href="/contact" tabindex="-1">Contact</a>
</nav>
```

```js
const button = document.querySelector('.menu-button');
const menu = document.getElementById('menu');
const focusable = 'a[href], button, input, select, textarea, [tabindex]';
button.addEventListener('click', () => {
  const open = button.getAttribute('aria-expanded') !== 'true';
  button.setAttribute('aria-expanded', String(open));
  menu.classList.toggle('is-open', open);
  menu.setAttribute('aria-hidden', String(!open));
  for (const item of menu.querySelectorAll(focusable)) {
    if (open) item.removeAttribute('tabindex');
    else item.tabIndex = -1;
  }
});
```

The CSS stays as it was in the Before. With every link at `tabindex="-1"`, Tab skips the closed menu, so the menu is hidden from the keyboard and screen readers together. It holds only while the selector finds every control in the menu, both in the markup and each time the script runs: a search field, a language switcher or a widget a plugin drops in later puts a hidden stop back. The script also removes any `tabindex` a control had of its own when the menu opens.

## Which option to use

Each of these passes. Hiding with CSS is the simplest when the closed menu is entirely out of view, which is the usual case: one rule, supported everywhere, and nothing for a script to forget. `inert` does the same job while leaving the content drawn, so choose it when part of what is out of use must still show, such as the edge of the next carousel slide or the page behind a custom dialog. Keeping `aria-hidden` with `tabindex="-1"` on each control is the smallest change to an existing component, but it has to be repeated for every control and reset on every open and close, so treat it as a stopgap until you can move to one of the others.

## Implementation decisions

Move focus before you hide what holds it. If the menu closes while focus is on a link inside it, as it does with Escape or a close button in the menu, hiding the menu first leaves focus on an element nobody can reach. Browsers then drop focus to the start of the page. Put focus on the Menu button, then hide the menu. When the menu opens, show it first, by removing `visibility: hidden` or `inert`, and only then move focus into it.

An open menu that covers part of the page needs care too. If Tab can leave the menu for a link underneath it, focus lands where nobody can see it. Either let the open menu push the page aside, so nothing after it is covered, or make it modal: move focus in, make the rest of the page inert and close the menu on Escape, as in the `modal-focus-management` guide.

The same failure has different fixes depending on what is hidden:

- Carousel slides out of view: `inert` on each slide that isn't shown, or `visibility: hidden` once the slide has moved out. Check the carousel library's own option before patching its markup.
- Tab panels behind the active tab: the `hidden` attribute on each panel that isn't selected.
- The page behind a custom dialog: `inert` on the page, as in the `modal-focus-management` guide.
- A repeated photo link in a product card that goes where the title link goes: `aria-hidden="true"` with `tabindex="-1"` on that one link is fine, since it holds no other control, or join the photo and title into one link.

If the hidden content is on screen and meant to be used, the fix is the other way round: remove `aria-hidden`, so everyone gets it. And check the rendered page, not only the template: menus, carousels and cookie tools from third-party scripts often add `aria-hidden` themselves.

## Verify the fix

1. Run axe with the menu closed and again with it open: `aria-hidden-focus` reports nothing, and in the accessibility tree nothing in the closed menu is listed or in the tab order.
2. With the menu closed, Tab from the Menu button: the next stop is the first control shown after it, the focus ring stays in view, and Shift + Tab comes straight back.
3. Open the menu and Tab through it: every link is reached, in order, with its ring showing.
4. Close the menu while focus is on a link inside it, in every way it closes: focus lands on the Menu button.
5. Repeat steps 2 to 4 with a screen reader: every stop is announced with its name and role, and none is silent.

Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat after the shared menu, carousel or dialog component changes.

## Limits

This is a starting pattern, not a guarantee for every application. It covers content hidden with `aria-hidden` that focus can still reach. `aria-hidden` on something that can't take focus but carries information people need, such as a price or a warning, is a different failure that this guide doesn't fix. What a screen reader says on a hidden link varies between browsers and screen readers, so test with the ones your users have.
