Building an Accessible Modal in React: Focus Trapping, Inert Backgrounds, and Escape Key Handling
A comprehensive technical guide on building a bulletproof, accessible React modal from scratch with TypeScript, covering focus trapping, inert backgrounds, and keyboard navigation.
Building an Accessible Modal in React: Focus Trapping, Inert Backgrounds, and Escape Key Handling
Modals are one of the most common UI patterns in modern web applications. Yet, they are also one of the most frequently mishandled components when it comes to web accessibility (a11y).
If you have ever opened a modal, pressed the Tab key a few times, and watched your focus slip out into the background content behind an invisible backdrop, you’ve witnessed an accessibility failure. Screen reader users face even worse issues: background content can remain exposed to assistive technologies, causing utter confusion.
In this technical walkthrough, we will build a bulletproof, accessible React modal component from scratch using TypeScript. We will cover:
- Focus Trapping: Keeping keyboard focus strictly inside the modal.
- The
inertAttribute: Silencing background content for screen readers and pointer devices alike. - Escape Key Handling: Dismissing the modal gracefully via keyboard.
- Focus Restoration: Returning focus to the trigger element when the modal closes.
The Anatomy of an Accessible Modal
Before writing code, let’s establish the requirements mandated by the WAI-ARIA Authoring Practices Guide (APG) for Dialog Modal patterns:
- Role: The element must have
role="dialog"andaria-modal="true". - Labeling: It must have an accessible name via
aria-labelledbyoraria-label. - Focus Management: When the modal opens, focus must move to an element inside the modal. While open, focus must be trapped. When closed, focus must return to the element that triggered it.
- Interactivity: Pressing
Escapemust close the modal.
Step 1: Setting up the Component and Portal
To prevent CSS stacking context (z-index) and layout containment issues, our modal should render into a DOM node outside the main application root using React Portals.
import React, { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
export const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children }) => {
if (!isOpen) return null;
return createPortal(
<div className="modal-overlay">
<div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
className="modal-content"
>
<h2 id="modal-title">{title}</h2>
{children}
<button onClick={onClose} aria-label="Close modal">
Close
</button>
</div>
</div>,
document.body
);
};
Step 2: Focus Restoration and Trigger Tracking
When a user clicks a “Open Settings” button, the modal appears. When they close it, their keyboard focus must return to that exact “Open Settings” button. If focus drops back to document.body, keyboard and screen reader users lose their place on the page.
We achieve this by capturing document.activeElement right before the modal opens, and restoring it when the modal unmounts.
// Inside Modal component
const previousActiveElementRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (isOpen) {
previousActiveElementRef.current = document.activeElement as HTMLElement;
} else if (previousActiveElementRef.current) {
previousActiveElementRef.current.focus();
}
}, [isOpen]);
Step 3: Implementing the Focus Trap
When a modal is open, pressing Tab must cycle through the focusable elements inside the modal and never escape to the document body. Pressing Shift + Tab should cycle backwards.
To build this, we query all focusable elements within our modal container and intercept the keydown event.
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isOpen) return;
const modalElement = modalRef.current;
if (!modalElement) return;
// Query all focusable elements
const focusableSelectors = [
'a[href]',
'area[href]',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'button:not([disabled])',
'iframe',
'object',
'embed',
'[contenteditable]',
'[tabindex]:not([tabindex="-1"])',
].join(',');
const focusableElements = modalElement.querySelectorAll<HTMLElement>(focusableSelectors);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
// Automatically focus the first element on open
firstElement?.focus();
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
if (e.key === 'Tab') {
if (focusableElements.length === 0) {
e.preventDefault();
return;
}
if (e.shiftKey) {
// Shift + Tab
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement?.focus();
}
} else {
// Tab
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement?.focus();
}
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen, onClose]);
Step 4: Making the Background inert
Historically, developers relied on aria-hidden="true" applied to the root application container to hide background content from screen readers. However, this approach had caveats: sighted keyboard or mouse users could still interact with background content unless custom click-eaters and tab-indexes were managed.
Today, modern browsers support the HTML inert attribute. When an element is marked as inert, the browser:
- Hides the element and all its descendants from accessibility trees (screen readers).
- Prevents click and touch events.
- Ignores the content during keyboard navigation and text searches.
We can target our application root element (e.g., <div id="root">) and toggle the inert property.
useEffect(() => {
if (!isOpen) return;
// Assuming your main app container has an id of 'root'
const appRoot = document.getElementById('root');
if (!appRoot) return;
appRoot.setAttribute('inert', 'true');
return () => {
appRoot.removeAttribute('inert');
};
}, [isOpen]);
Note: For older browsers that do not support
inertnatively, you can include the official W3Cw3c/inertpolyfill to ensure robust fallback behavior.
Putting It All Together
Here is our complete, production-ready Modal component combining all these accessibility patterns:
import React, { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
appRootId?: string;
}
export const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
children,
appRootId = 'root',
}) => {
const modalRef = useRef<HTMLDivElement>(null);
const previousActiveElementRef = useRef<HTMLElement | null>(null);
// 1. Focus restoration & Inert background handling
useEffect(() => {
if (isOpen) {
previousActiveElementRef.current = document.activeElement as HTMLElement;
const appRoot = document.getElementById(appRootId);
if (appRoot) {
appRoot.setAttribute('inert', 'true');
}
}
return () => {
if (isOpen) {
const appRoot = document.getElementById(appRootId);
if (appRoot) {
appRoot.removeAttribute('inert');
}
if (previousActiveElementRef.current) {
previousActiveElementRef.current.focus();
}
}
};
}, [isOpen, appRootId]);
// 2. Focus trap & Escape key handler
useEffect(() => {
if (!isOpen) return;
const modalElement = modalRef.current;
if (!modalElement) return;
const focusableSelectors = [
'a[href]',
'area[href]',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'button:not([disabled])',
'iframe',
'object',
'embed',
'[contenteditable]',
'[tabindex]:not([tabindex="-1"])',
].join(',');
const focusableElements = modalElement.querySelectorAll<HTMLElement>(focusableSelectors);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
firstElement?.focus();
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
if (e.key === 'Tab') {
if (focusableElements.length === 0) {
e.preventDefault();
return;
}
if (e.shiftKey && document.activeElement === firstElement) {
e.preventDefault();
lastElement?.focus();
} else if (!e.shiftKey && document.activeElement === lastElement) {
e.preventDefault();
firstElement?.focus();
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return createPortal(
<div className="modal-backdrop" onClick={onClose}>
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
className="modal-container"
onClick={(e) => e.stopPropagation()} // Prevent closing when clicking modal body
>
<header className="modal-header">
<h2 id="modal-title">{title}</h2>
<button onClick={onClose} aria-label="Close modal" className="close-btn">
×
</button>
</header>
<div className="modal-body">{children}</div>
</div>
</div>,
document.body
);
};
Conclusion
Building accessible UI components requires intentional effort beyond basic markup. By implementing focus trapping, leveraging the native inert attribute, handling Escape key events, and managing focus restoration, you ensure that users of all abilities can navigate your React applications seamlessly.
Take this component, add your organization’s design system styles, and build confidently knowing your modals meet the highest standards of web accessibility.