[dialog] Modal applies aria-hidden to background but doesn't remove focusable elements from tab order (aria_hidden_nontabbable violation)
- 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
Assessment
This issue has not been assessed yet.