Skip to main content

The `inert` Attribute vs. `aria-hidden`: Managing Focus in Animated Drawers

 One of the most persistent accessibility violations in modern single-page applications occurs during the transition states of off-canvas menus (drawers).

We build performant, 60fps animations using CSS transforms to slide a drawer into the viewport. Visually, the drawer covers the main content. However, unless the DOM is explicitly managed, the "background" content remains interactive. Keyboard users can Tab completely out of the drawer and interact with invisible links, forms, and buttons underneath the backdrop.

Historically, developers relied on aria-hidden="true" or complex "focus trap" JavaScript loops to mitigate this. Both approaches are flawed. This post details why aria-hidden is insufficient for interaction management and how the inert attribute provides the browser-native solution.

The Root Cause: The Disconnect Between Accessibility and Focus

To understand why the common fixes fail, we must distinguish between the Accessibility Tree and the Sequential Focus Navigation Order.

The aria-hidden Fallacy

A common mistake is applying aria-hidden="true" to the main content wrapper when a modal or drawer opens.

<!-- INCORRECT IMPLEMENTATION -->
<main aria-hidden="true">
  <a href="/settings">Settings</a> <!-- Still focusable! -->
</main>
<aside class="drawer">...</aside>

aria-hidden="true" successfully removes the element and its children from the Accessibility Tree. Screen readers will ignore the content. However, it does not prevent focus.

This creates a WCAG violation (Success Criterion 2.1.1). A keyboard user can tab into the <main> area. Because the content is hidden from the accessibility API, the screen reader provides no feedback—or worse, confusing feedback—about where the focus is. The user is effectively navigating a "black hole."

The "Focus Trap" Complexity

The traditional workaround involves JavaScript "focus traps"—event listeners that detect when the user tabs past the last element of the drawer and forcibly move focus back to the first element.

While functional, focus traps are:

  1. Fragile: They often fight with browser heuristics.
  2. Performance Heavy: They require constant event monitoring.
  3. Incomplete: They rarely prevent mouse users from clicking buttons in the background if the backdrop overlay fails to capture pointer-events.

The Fix: Implementing inert

The inert global attribute is the correct standard for this behavior. When an element is inert:

  1. It is removed from the Accessibility Tree (acting like aria-hidden="true").
  2. It is removed from the tab order (acting like tabindex="-1" on all children).
  3. It ignores pointer events (acting like CSS pointer-events: none).

Below is a robust React implementation using TypeScript. We use a ref to toggle the attribute to ensure type safety and direct DOM control, bypassing potential framework-specific attribute whitelisting issues.

1. The React Implementation

import { useEffect, useRef, useState } from 'react';
import styles from './Layout.module.css';

export const AppLayout = () => {
  const [isDrawerOpen, setIsDrawerOpen] = useState(false);
  
  // References to the main content and the drawer
  const mainRef = useRef<HTMLElement>(null);
  const drawerRef = useRef<HTMLElement>(null);
  const closeBtnRef = useRef<HTMLButtonElement>(null);

  // Effect: Manage 'inert' state and focus
  useEffect(() => {
    const mainEl = mainRef.current;
    const drawerEl = drawerRef.current;

    if (!mainEl || !drawerEl) return;

    if (isDrawerOpen) {
      // 1. Freeze the background content
      mainEl.setAttribute('inert', '');
      
      // 2. Prevent body scroll (visual only)
      document.body.style.overflow = 'hidden';

      // 3. Move focus to the drawer (specifically the close button or first interactive el)
      // RequestAnimationFrame ensures the drawer is rendered/visible before focusing
      requestAnimationFrame(() => {
        closeBtnRef.current?.focus();
      });
    } else {
      // 1. Unfreeze content
      mainEl.removeAttribute('inert');
      
      // 2. Restore scroll
      document.body.style.overflow = '';
      
      // Note: In a real app, you should restore focus to the 
      // button that opened the drawer here.
    }
  }, [isDrawerOpen]);

  return (
    <div className={styles.wrapper}>
      {/* 
        CRITICAL: The drawer must be a sibling to the inert content, 
        not a child. If the drawer is inside the inert element, 
        it becomes inert too.
      */}
      
      <main ref={mainRef} className={styles.mainContent}>
        <header>
          <h1>Dashboard</h1>
          <button 
            onClick={() => setIsDrawerOpen(true)}
            aria-expanded={isDrawerOpen}
            className={styles.menuButton}
          >
            Open Settings
          </button>
        </header>
        
        <section>
          <p>
            This link is <a href="#">focusable</a> only when drawer is closed.
          </p>
          <form>
            <label>
              Search
              <input type="text" placeholder="Type here..." />
            </label>
          </form>
        </section>
      </main>

      <aside 
        ref={drawerRef} 
        className={`${styles.drawer} ${isDrawerOpen ? styles.open : ''}`}
        aria-label="User Settings"
      >
        <div className={styles.drawerInner}>
          <button 
            ref={closeBtnRef}
            onClick={() => setIsDrawerOpen(false)}
            className={styles.closeButton}
            aria-label="Close Settings"
          >
            &times;
          </button>
          <nav>
            <ul>
              <li><a href="/profile">Profile</a></li>
              <li><a href="/account">Account</a></li>
            </ul>
          </nav>
        </div>
        {/* Backdrop to capture clicks outside */}
        <div 
          className={styles.backdrop} 
          onClick={() => setIsDrawerOpen(false)} 
          aria-hidden="true"
        />
      </aside>
    </div>
  );
};

2. The CSS (Modern & Performant)

We use CSS Custom Properties and transforms to ensure the animation runs on the compositor thread, preventing layout thrashing.

/* Layout.module.css */
.wrapper {
  position: relative;
  overflow-x: hidden;
}

.mainContent {
  transition: filter 0.3s ease;
}

/* Optional visual cue that content is inert */
.mainContent[inert] {
  filter: blur(2px) grayscale(50%);
  user-select: none;
}

.drawer {
  position: fixed;
  top: 0;
  right: 0;
  height: 100vh;
  width: 300px;
  z-index: 100;
  
  /* Start off-screen */
  transform: translateX(100%);
  transition: transform 0.3s cubic-bezier(0.4, 0.0, 0.2, 1);
  will-change: transform;
}

.drawer.open {
  transform: translateX(0);
}

.drawerInner {
  background: white;
  height: 100%;
  padding: 2rem;
  position: relative;
  z-index: 2;
  box-shadow: -4px 0 15px rgba(0,0,0,0.1);
}

.backdrop {
  position: fixed;
  inset: 0;
  background: rgba(0,0,0,0.5);
  opacity: 0;
  transition: opacity 0.3s ease;
  pointer-events: none; /* Let clicks pass through when closed */
  z-index: 1;
}

.drawer.open .backdrop {
  opacity: 1;
  pointer-events: auto; /* Capture clicks when open */
}

Why This Works

DOM Subtree Freezing

When mainEl.setAttribute('inert', '') executes, the browser engine recursively traverses the <main> DOM tree. It computes the focusability of every child element as false, regardless of their individual tabindex or semantic role. This is done at the C++ level in the browser engine, making it significantly more performant than a JavaScript recursive walker.

Interaction Blocking

Unlike pointer-events: none, which only stops mouse/touch events, inert blocks all forms of interaction. This includes:

  • Text selection (User cannot highlight text in the background).
  • find-in-page (Cmd+F results in the inert section are skipped).
  • Assistive technology navigation (VoiceOver/NVDA will treat the region as if it doesn't exist).

Structural Requirement

Notice the HTML structure in the solution:

<body>
  <main ref={mainRef}>...</main>  <!-- Becomes Inert -->
  <aside ref={drawerRef}>...</aside> <!-- Remains Active -->
</body>

If the <aside> were nested inside the <main>, applying inert to <main> would also disable the drawer. Sibling architecture is mandatory for this pattern. If your application wraps everything in a single Root Layout div, you must move your Modals/Drawers to a React Portal (createPortal) that renders them outside that root div to effectively use inert.

Conclusion

The inert attribute is now supported in all major evergreen browsers (Chrome 102+, Firefox 112+, Safari 16+). It replaces the need for aria-hidden when managing focus states and eliminates the need for fragile focus-trap libraries.

By using inert, you align the visual state of your application (a drawer covering content) with the functional state of the browser (disabling the covered content), ensuring a consistent experience for mouse, keyboard, and screen reader users alike.