Using inert to Isolate Background Content

Permalink to "Using inert to Isolate Background Content"

The inert attribute makes an element and all its descendants non-interactive: they cannot be focused, clicked or selected, and they are removed from the accessibility tree. It is the primitive behind modal dialogs, exposed so that authors can apply it to other blocking UI — a side panel that must be completed before returning to the table, a full-page processing overlay, the inactive steps of a wizard.

Before inert, isolating content meant combining aria-hidden="true" (hides from assistive technology but leaves it focusable), tabindex="-1" on every focusable descendant, and pointer-event hacks — three mechanisms that routinely drifted out of sync. This page covers when inert is the right tool and how to apply it without trapping users. It belongs to keyboard focus trapping & navigation.

Spec reference

Permalink to "Spec reference"

inert is a boolean global attribute in the HTML Living Standard. On an inert element and its subtree:

  • Focus cannot move to any element; if a focused element becomes inert, focus is removed from it.
  • Pointer events are ignored (clicks pass to nothing), and text selection is blocked.
  • Content is hidden from assistive technology — equivalent to aria-hidden="true" on the subtree.
  • Find-in-page skips it.

The attribute cannot be overridden by descendants: an element inside an inert subtree cannot opt back in. That is why inert must go on siblings of the active region, not on a shared ancestor.

Criteria: SC 2.4.3 Focus Order (focus must not wander into blocked content), SC 1.3.1 (screen readers must not read content that is not currently usable as if it were), and SC 2.1.2 No Keyboard Trap (the blocking state must be escapable).

Inert siblings around an active panel Layers of a page with an active edit panel: header, filters and the table are inert siblings; the panel is the only interactive region. Inert siblings around an active panelSite headerinert — no focus, no clicks, hidden from ATFilters and toolbarinertData tableinert — visibly dimmedEdit panel (active)not inert; focus moved here on open
inert goes on each sibling — putting it on a shared ancestor would disable the panel too.

When to use inert — and when not to

Permalink to "When to use inert — and when not to"

Use it for blocking states that are not dialogs: a slide-in edit panel implemented as an <aside>, a processing overlay during a bulk operation that must not be interrupted, the later steps of a wizard that are visible but not yet available.

Use <dialog> with showModal() instead when the blocking UI is a dialog; it applies inertness to the rest of the page for you, as described in native dialog versus custom focus traps.

Do not use inert for loading states the user can wait through while still reading. A table refreshing its data should stay readable; mark it aria-busy and keep the old rows visible, as in using aria-busy during progressive table loads.

The misapplication to name is aria-hidden="true" on the page behind a custom modal. The background disappears from the screen reader’s view but remains focusable, so Tab moves focus into content the screen reader claims does not exist — the user hears nothing, and focus is lost somewhere invisible.

Annotated code example

Permalink to "Annotated code example"
<body>
  <header id="site-header">…</header>
  <main>
    <section id="filters">…</section>
    <section id="table-region">…</section>
  </main>
  <!-- the active region is a sibling of the blocked ones -->
  <aside id="edit-panel" aria-labelledby="panel-title" hidden>
    <h2 id="panel-title" tabindex="-1">Edit INV-1042</h2>
    …
  </aside>
</body>
const blocked = ['site-header', 'filters', 'table-region'].map((id) => document.getElementById(id));
let returnTo = null;

function openPanel(trigger) {
  returnTo = trigger;
  const panel = document.getElementById('edit-panel');
  panel.hidden = false;
  blocked.forEach((el) => { el.inert = true; });     // SC 2.4.3 + 1.3.1
  panel.querySelector('#panel-title').focus();       // move focus INTO the active region
  document.addEventListener('keydown', onEscape);    // SC 2.1.2: a way out
}

function closePanel() {
  blocked.forEach((el) => { el.inert = false; });
  document.getElementById('edit-panel').hidden = true;
  document.removeEventListener('keydown', onEscape);
  (returnTo?.isConnected ? returnTo : document.querySelector('#table-region caption'))?.focus();
}

function onEscape(e) { if (e.key === 'Escape') closePanel(); }
/* Sighted users need to see that inert content is unavailable */
[inert] { opacity: 0.5; filter: grayscale(0.4); }
@media (prefers-reduced-transparency: reduce) { [inert] { opacity: 0.7; } }

Order matters in openPanel: making the background inert before moving focus means that if the previously focused element is inside the blocked area, the browser clears its focus, and your explicit focus() call then places it in the panel. Reversing the order works too, but focusing first and applying inert second can leave a flash where focus sits on an element about to become inert.

Keyboard & AT behaviour

Permalink to "Keyboard & AT behaviour"
Event Expected behaviour Failure indicator
Panel opens Focus on the panel heading; “Edit INV-1042, heading level 2” Focus stays on the trigger inside the table
Tab from last panel control Moves to browser chrome or wraps to panel start Moves into the dimmed table
Screen reader virtual cursor Reads only the panel Reads table content behind the panel
Escape Panel closes, inert removed, focus back to trigger Panel closes but background stays inert
Click on dimmed table Nothing happens Row actions fire behind the panel
aria-hidden versus inert on background content Comparison of hiding background content with aria-hidden against making it inert, across focus, pointer input and screen reader visibility. aria-hidden versus inert on background content✗ aria-hidden="true"Hidden from the screen readerStill focusable with TabStill clickableFocus can land in "nothing"✓ inertHidden from the screen readerNot focusableNot clickable or selectableOne attribute, consistent everywhere
aria-hidden hides content from speech but not from Tab — the mismatch is the bug.

Integration context

Permalink to "Integration context"

Inert panels still need a way back. Restoring focus to the trigger — or to a substitute when the trigger was rebuilt — follows restoring focus after closing complex modals.

For multi-step flows in data apps — an import wizard, a report builder — applying inert to future steps keeps them visible for orientation while preventing premature interaction. Pair it with a heading per step and an aria-current="step" indicator in the step list.

Panel open to panel close Timeline of an inert-based edit panel: trigger activated, siblings made inert, focus moved into the panel, work done, Escape pressed, inert removed and focus restored. Panel open to panel closeTrigger"Edit INV-1042" activatedSiblings inertheader, filters, tableFocus inpanel heading readEscapeor Save or CancelInert removedfocus back to triggerone blocking panel session
Every state change on the way in has a matching change on the way out.

Gotchas

Permalink to "Gotchas"

Inert on an ancestor. Setting inert on <main> when the panel lives inside <main> disables the panel too, with no way for descendants to opt out. Structure the DOM so the active region is a sibling.

Forgotten cleanup. An exception thrown during close leaves the page permanently inert — every control dead. Wrap cleanup in finally, and remove inert before anything else.

Live regions inside inert content. A status region inside an inert subtree is hidden from assistive technology and its announcements are lost. Keep the page’s live regions outside any subtree you might make inert.

Testing checklist

Permalink to "Testing checklist"

FAQ

Permalink to "FAQ"
What is the difference between inert and aria-hidden?

aria-hidden removes content from the accessibility tree only; it stays focusable and clickable. inert removes it from the accessibility tree and also makes it unfocusable and unclickable, so focus and speech stay consistent.

Can an element inside an inert subtree be made interactive again?

No. Descendants cannot override inert. Put the interactive region outside the inert subtree, as a sibling of the elements you block.

Do I need inert if I use the dialog element?

Not for the dialog itself: showModal() makes the rest of the document inert automatically. Use the inert attribute directly for blocking interfaces that are not dialogs, such as slide-in panels or wizard steps.

Should a loading table be made inert?

Usually not. Users can keep reading the current data while new data loads. Mark the table aria-busy and announce when loading finishes; reserve inert for states where interaction would be harmful.

Permalink to "Related"

← Back to Keyboard Focus Trapping & Navigation for Data Interfaces