WSS
BlogAccessibilityModern Web APIsPublished

Inert, Dialog, and the Death of Z-Index Hacks

For fifteen years, we wrote complex JavaScript to trap focus in modals and fought endless z-index wars. The native web has finally solved this. Here is a deep dive into the <dialog> element and the inert attribute.

If you have built web interfaces for more than a few years, you carry the scars of the Modal Wars.

Building a truly accessible, robust modal window using <div>s historically required an enormous amount of boilerplate. You had to:

  1. Append the modal to the very end of the DOM <body> to escape the stacking context of its parent containers.
  2. Set z-index: 9999 (and pray a marketing widget didn’t use 99999).
  3. Write complex JavaScript to intercept the Tab key and trap keyboard focus inside the modal so screen reader users wouldn’t accidentally tab into the background.
  4. Manually manage aria-hidden="true" on the main content wrapper.
  5. Restore focus to the triggering button when the modal closed.

This was a massive tax on developers, and because it was so difficult, the vast majority of web modals were (and still are) completely inaccessible.

The platform has finally caught up. With the universal adoption of the native <dialog> element and the inert HTML attribute, all of this boilerplate is obsolete.

The Native <dialog> Element

The <dialog> element represents a part of an application that a user interacts with to perform a task, such as a dialog box or inspector.

When used correctly, it completely nullifies the need for z-index hacks and focus traps.

The Top Layer

The most powerful feature of the <dialog> element is its relationship with the Top Layer.

When you open a dialog using the showModal() JavaScript method, the browser takes that element and elevates it to a special, system-level rendering context called the Top Layer.

<dialog id="delete-confirmation">
  <h2>Delete Account</h2>
  <p>This action cannot be undone.</p>
  <form method="dialog">
    <button value="cancel">Cancel</button>
    <button value="confirm" class="danger">Delete</button>
  </form>
</dialog>

<script>
  const modal = document.getElementById('delete-confirmation');
  
  // This elevates the dialog to the Top Layer
  modal.showModal(); 
</script>

The Top Layer renders outside the normal document flow. It sits above all other elements in the browser window, regardless of their z-index. You cannot out-z-index the Top Layer. Even an element with z-index: 2147483647 will render behind a <dialog> that has been opened with showModal().

This solves the stacking context problem permanently. You no longer need to use React Portals to append your modals to the document.body. You can leave the <dialog> deeply nested in your component tree right next to the button that triggers it, and the browser will still render it on top of everything else.

Native Focus Trapping and the ::backdrop

When a <dialog> is opened via showModal() (as opposed to the standard show() method, which opens a non-modal dialog), the browser automatically does two critical things for accessibility:

  1. It traps focus. You can press Tab as much as you want; your cursor will cycle through the focusable elements inside the dialog and will not escape into the background content. You no longer need a third-party focus-trap-react library.
  2. It obscures the background. The browser automatically makes the rest of the document untouchable.

Furthermore, it exposes a new CSS pseudo-element: ::backdrop.

dialog::backdrop {
  background-color: rgba(0, 0, 0, 0.8);
  backdrop-filter: blur(4px);
}

The ::backdrop pseudo-element allows you to style the dimming overlay behind the modal. Because it is part of the Top Layer, it is guaranteed to stretch perfectly across the viewport without requiring position: fixed, width: 100vw, or height: 100vh hacks.

The inert Attribute

While <dialog> handles modals perfectly, what about custom off-canvas drawers, sidebars, or complex custom overlays that don’t quite fit the modal paradigm?

Historically, to prevent screen readers from reading the background content while a drawer was open, you had to manually apply aria-hidden="true" and tabindex="-1" to all sibling elements of the drawer.

The inert attribute replaces this entirely.

<div id="app" inert>
  <!-- 
    The entire application is now completely ignored by 
    screen readers, tab navigation, and mouse clicks. 
  -->
  <header>...</header>
  <main>...</main>
</div>

<aside id="drawer">
  <!-- This is outside the inert container, so it remains active -->
  <nav>...</nav>
</aside>

When an element possesses the boolean inert attribute:

  • The browser ignores all user input events (clicks, touches) for that element and its descendants.
  • The element and its descendants are removed from the tab order.
  • The element and its descendants are hidden from assistive technologies (like screen readers).

It is a true, native “pause” button for any section of the DOM.

Performance Implications

Beyond accessibility, inert provides a subtle performance boost. When an element is inert, the browser knows that it cannot be interacted with and does not need to compute hover states, focus rings, or specific pointer events for that entire DOM tree. In applications with massive, complex layouts, aggressively applying inert to off-screen or inactive routes can save precious main-thread computation time.

Conclusion

The web platform is slowly but surely absorbing the massive JavaScript libraries we used to rely on.

If you are starting a new project or refactoring an old one, delete your custom modal components. Delete your focus-trapping scripts. Delete your global z-index variables.

Embrace <dialog>, utilize the Top Layer, and use inert for custom accessibility states. Your bundle size will shrink, your code will be vastly simpler, and your users—especially those relying on assistive technology—will have a far superior experience.

Related posts