Skip to content

Inert Controller

When to use

Use the inert controller when a modal dialog or overlay opens and you need the background content to become non-interactive — unfocusable by keyboard, invisible to screen readers, and unclickable. The HTML inert attribute does this natively, but if multiple overlays can be open at the same time (e.g. a confirmation dialog on top of a settings panel), you need a way to track how many layers want the background inert and only remove the attribute when all of them have closed.

The pattern

Using InertController

InertController tracks how many overlays want the background to be inert. Call on() when a modal opens and off() when it closes. The background stays inert as long as at least one layer is active, so nested modals work without conflict:

javascript
const inert = new InertController();

function openDialog() {
  inert.on();
  dialog.showModal();
}

function closeDialog() {
  dialog.close();
  inert.off();
}

Multiple overlays can safely stack. Each on() increments a counter and each off() decrements it — the inert attribute is only removed when the counter reaches zero:

javascript
const inert = new InertController();

// First modal opens — inert="1"
inert.on();

// Confirmation dialog opens on top — inert="2"
inert.on();

// Confirmation closes — inert="1"
inert.off();

// First modal closes — inert removed
inert.off();

Implementing InertController

InertController stores a reference count in the inert attribute's value. The browser treats any truthy value as "inert," so the number has no effect on browser behavior but gives the controller a place to keep its count without extra bookkeeping:

javascript
class InertController {
  constructor(selector = "[data-app]") {
    this.selector = selector;
  }
  
  get targetElement() {
    return document.querySelector(this.selector);
  }
  
  /** Add one inert layer. */
  on() {
    const el = this.targetElement;
    const count =
      Number(el.getAttribute("inert") ?? 0) + 1;
    el.setAttribute("inert", count);
  }
  
  /** Remove one inert layer. */
  off() {
    const el = this.targetElement;
    const count =
      Number(el.getAttribute("inert") ?? 0) - 1;
    if (count > 0) {
      el.setAttribute("inert", count);
    } else {
      el.removeAttribute("inert");
    }
  }
}

For a Vue composable wrapper, see useInert in the vue skill.

Pairing with scroll lock

In practice, InertController pairs with scroll locking. When a modal opens, lock scroll and enable inert; when it closes, reverse both:

javascript
const inert = new InertController();
const scrollLock = new ScrollLock();

function open() {
  dialog.show();
  scrollLock.lock();
  inert.on();
}

function close() {
  dialog.close();
  scrollLock.unlock();
  inert.off();
}

Trade-offs

  • Counter in the attribute. The number stored in the inert attribute is invisible to the browser — it only cares that the attribute exists. This is a pragmatic hack, not a standard use of the attribute. If third-party code checks element.inert (the boolean property), it will still see true while any layer is active, which is correct.
  • Single target element. The controller targets one element via a CSS selector. If your app has multiple root containers that need separate inert management, use one controller per container.
  • No automatic cleanup. If a modal's close handler fails to call off(), the counter gets stuck and the background stays inert. Pair with lifecycle hooks (e.g. onUnmounted in Vue or disconnectedCallback in web components) to guarantee cleanup.
  • Selector timing. The targetElement getter queries the DOM on every call. If the target element is removed and re-added (e.g. during a page transition), the controller adapts automatically. If the selector matches nothing, on() and off() will throw.