10LOC
Chrome APIsintermediate

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 = true on each remaining top-level sibling. inert cascades 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. inert has 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 inert back to false on each sibling it touched and hides the modal. Call it from your Esc handler, backdrop click, or close button.

Gotchas

  • inert reflects as a boolean IDL property, same footgun class as disabled/hidden: el.setAttribute("inert", "false") still makes it inert, because the attribute's presence is what matters, not its string value. Use the .inert property (as the snippet does) or removeAttribute("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.children once, 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 toggle inert on instead of iterating body.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 for inert directly when you can't use <dialog>.
  • aria-modal — an accessibility annotation only; it doesn't affect real tab order or click handling the way inert does, and AT support for it alone is inconsistent.
  • focus-trap and similar libraries — the JS-based Tab-interception approach inert replaces for this use case.