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:
- Fragile: They often fight with browser heuristics.
- Performance Heavy: They require constant event monitoring.
- 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:
- It is removed from the Accessibility Tree (acting like
aria-hidden="true"). - It is removed from the tab order (acting like
tabindex="-1"on all children). - 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"
>
×
</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.