Skip to content

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

@Jacksonmills

Description

@Jacksonmills

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:

// 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.

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()
  }, [])
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    component: dialogChanges related to the dialog component.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions