The inert attribute to trap focus outside a modal
Published February 12, 2027
export const openModal = (modalRoot: HTMLElement) => {
const siblings = Array.from(document.body.children).filter(
(el): el is HTMLElement => el !== modalRoot && el instanceof HTMLElement,
);
siblings.forEach((el) => {
el.inert = true;
});
modalRoot.hidden = false;
modalRoot.querySelector<HTMLElement>("[autofocus]")?.focus();
return () => {
siblings.forEach((el) => {
el.inert = false;
});
modalRoot.hidden = true;
};
};What
Setting the inert property (or attribute) on an element makes it and everything inside it unreachable — no focus, no clicks, no assistive-technology exposure — in one line, applied to any subtree, not just a native <dialog>.
Why it matters
<dialog>'s showModal() makes the rest of the page inert automatically, but plenty of modal-shaped UI isn't a <dialog> — a slide-over panel, a custom combobox listbox, a wizard overlay — usually for layout, animation, or stacking-context reasons that made <dialog> awkward to adopt. Those still need the exact same lockout: Tab shouldn't reach the page behind the overlay, a screen reader's virtual cursor shouldn't be able to swipe into it, and a stray click shouldn't focus a hidden form field. Before inert, that meant either aria-hidden plus manually walking every focusable descendant to set tabindex="-1" (fragile — miss one input and it's still tabbable) or a focus-trap library that intercepts Tab/Shift-Tab and redirects focus programmatically on every keystroke. inert does the whole job declaratively: mark the siblings inert, and the browser handles focus containment, click-through prevention, and accessibility-tree exclusion together, for the whole subtree, in one property.
How it works
openModal marks every sibling of the modal root inert, then returns a closer that undoes it:
- It reads
document.body.children, filters out the modal root itself, and sets.inert = trueon each remaining top-level sibling.inertcascades to "all flat-tree descendants" of the element it's set on, so marking one top-level container covers everything nested inside it — no need to walk the tree. modalRoot.querySelector("[autofocus]")?.focus()sets initial focus manually.inerthas no opinion on this — it only removes things from consideration, it doesn't decide what should be focused. (showModal()does this part for you; a hand-rolled modal doesn't get it for free.)- The returned closer flips
inertback tofalseon each sibling it touched and hides the modal. Call it from your Esc handler, backdrop click, or close button.
Gotchas
inertreflects as a boolean IDL property, same footgun class asdisabled/hidden:el.setAttribute("inert", "false")still makes it inert, because the attribute's presence is what matters, not its string value. Use the.inertproperty (as the snippet does) orremoveAttribute("inert")to actually clear it.- This only covers interaction and accessibility semantics — it doesn't dim anything visually. A still-visible, unclickable sibling reads as a bug to sighted users if you don't pair it with your own CSS (e.g.
[inert] { opacity: 0.5; }). - The snippet snapshots
document.body.childrenonce, at open time. Anything appended to<body>while the modal is open (a toast portal, a lazy-mounted widget) won't be inert unless you also mark it — either mark new top-level nodes as they're added, or wrap the whole non-modal app in a single stable container you can toggleinerton instead of iteratingbody.children. - Nesting matters: this assumes the modal root is a direct child of
<body>. If it's mounted deeper in the tree, filter at whatever level actually contains both the modal and everything that should become inert.
Browser support: Baseline since 2023 — Chrome/Edge 102+, Firefox 112+, Safari 15.5+. No polyfill needed for any currently-supported browser target.
Related
<dialog>+showModal()— does exactly this automatically for the rest of the page; reach forinertdirectly when you can't use<dialog>.aria-modal— an accessibility annotation only; it doesn't affect real tab order or click handling the wayinertdoes, and AT support for it alone is inconsistent.focus-trapand similar libraries — the JS-based Tab-interception approachinertreplaces for this use case.