mui / mui/base-ui

[dialog] Modal applies aria-hidden to background but doesn't remove focusable elements from tab order (aria_hidden_nontabbable violation)

Open
#4,678 2 comments 0 reactions 0 assignees View on GitHub
component: dialog
Dominant language
TypeScript
Stars
10.9k
Forks
543
Avg merge
1d 20h
Merged PRs (30d)
101

Description

# Bug report

## Current behavior

When a modal `Dialog` (or `AlertDialog`) opens, `FloatingFocusManager` applies `aria-hidden="true"` to all background elements to hide them from assistive technology. However, it does **not** remove those elements from sequential focus navigation. This means focusable elements (links, buttons, inputs) remain tabbable while sitting inside an `aria-hidden="true"` subtree which violates WCAG 2.1 SC 4.1.2.

Flagged by IBM Equal Access Checker and axe-core as:
> Rule: `aria_hidden_nontabbable` / Reason: `Fail_1`
> "Element should not be focusable within the subtree of an element with an aria-hidden attribute with value 'true'"

## Expected behavior

Background elements should be removed from both the accessibility tree and sequential focus navigation when a modal opens. This is best achieved using the `inert` HTML attribute rather than relying solely on `aria-hidden="true"`.

## Reproducible example

Open any Dialog in docs and run IBM Equal Access Checker or similar tool

## Base UI version

v1.4.1

## Which browser are you using?

Firefox

## Which OS are you using?

Windows

## Which assistive tech are you using (if applicable)?

Tested with IBM Equal Access Checker and axe-core.

## Additional context

Affects: `Dialog`, `AlertDialog`, and any component using modal `FloatingFocusManager`.

**Root cause**
`FloatingFocusManager` calls `markOthers` with `ariaHidden: true` when a modal opens, but never passes `inert: true`:

```ts
// FloatingFocusManager.tsx
const ariaHiddenCleanup = markOthers(insideElements, {
ariaHidden: modal || isUntrappedTypeableCombobox,
mark: false,
})
```

`aria-hidden` only removes elements from the accessibility tree. The `inert` HTML attribute (supported in all modern browsers) removes elements from both the accessibility tree and sequential focus navigation which is exactly what a modal background needs.

`supportsInert()` is already supported and full `inert` plumbing... it's just never used in this call path.

**Suggested fix**

Update the call to use `markerInsideElements` (for consistency with the marker call) and pass the `inert` flag conditionally based on browser support (supportsInert()).

**Workaround**
Observe `data-base-ui-inert` (the marker attribute already set by the second `markOthers` call) via `MutationObserver` and mirror `inert` onto the same elements.

> **Note:** `data-base-ui-inert` is also applied to base-ui's focus guard sentinel elements. Those must be excluded, making them `inert` removes them from the tab order and breaks focus trapping, allowing Tab to escape to the browser chrome.

```ts
function useInertBackground() {
useEffect(() => {
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type !== 'attributes') continue
const el = mutation.target as HTMLElement

// Skip focus guard sentinels — base-ui uses these to bounce Tab back
// into the dialog. Making them inert breaks focus trapping entirely.
if (el.hasAttribute('data-base-ui-focus-guard')) continue

if (el.hasAttribute('data-base-ui-inert')) {
el.setAttribute('inert', '')
} else {
el.removeAttribute('inert')
}
}
})

observer.observe(document.body, {
subtree: true,
attributes: true,
attributeFilter: ['data-base-ui-inert'],
})

return () => observer.disconnect()
}, [])
}
```

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.