Building an Accessible Modal Dialog in React: Focus Trapping and Inert Backgrounds
A practical, production-ready guide to building a fully accessible React modal from scratch with focus trapping, ARIA roles, escape key handling, and modern inert background management.
Building an Accessible Modal Dialog in React: Focus Trapping and Inert Backgrounds
Modal dialogs are one of the most common UI patterns in modern web applications. They are used for everything from simple alert confirmations to complex data entry forms. However, they are also one of the most frequently mishandled components when it comes to web accessibility (a11y).
A truly accessible modal requires much more than just a centered <div> with a high z-index and a backdrop. Screen reader users, keyboard-only navigators, and users with low vision all rely on specific behavioral cues to understand and interact with modals effectively.
In this guide, we will build a production-ready, highly accessible modal dialog component from scratch in React using TypeScript. We will cover:
- Proper semantic HTML and ARIA attributes
- Keyboard navigation and focus trapping
- The native HTML
inertattribute for background management - Restoring focus when the modal closes
- Managing escape key closures and backdrop clicks
The Core Requirements of an Accessible Modal
Before writing any code, let’s outline the UX and accessibility contract a modal must fulfill:
- Semantic Structure: Screen readers must immediately announce the modal’s presence, its title, and its description.
- Inert Background: Everything outside the modal must be hidden from screen readers and removed from the keyboard tab order.
- Focus Trapping: Keyboard focus must remain trapped inside the modal while it is open. Pressing
TaborShift + Tabshould cycle through the focusable elements within the modal without escaping into the background DOM. - Initial Focus: When the modal opens, focus should immediately move to a logical element inside the modal (such as the close button or the first form input).
- Return Focus: When the modal closes, focus must return to the exact element that triggered the modal.
- Escape Key Handling: Pressing the
Escapekey must close the modal.
Step 1: Semantic ARIA Roles and Attributes
Let’s start by designing the skeletal structure of our modal component. We need to use specific ARIA roles to inform assistive technologies about the dialog’s behavior.
import React, { useEffect, useRef } from 'react';
import ReactDOM 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 ReactDOM.createPortal(
<div className="modal-backdrop">
<div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
className="modal-content"
>
<h2 id="modal-title">{title}</h2>
<button onClick={onClose} aria-label="Close modal">
X
</button>
<div>{children}</div>
</div>
</div>,
document.body
);
};
Breaking down the ARIA attributes:
role="dialog": Tells screen readers that this element is a dialog window.aria-modal="true": Crucial for instructing assistive technologies to hide elements outside of this dialog (though we will reinforce this with theinertattribute).aria-labelledby="modal-title": Links the dialog to its header so screen readers automatically announce the title when the modal opens.
Step 2: Managing Background Content with the inert Attribute
Historically, hiding background content from screen readers required walking the DOM tree and toggling aria-hidden="true" on every sibling of the modal container. Today, modern browsers support the native HTML inert attribute.
When an element is marked as inert:
- It and all its descendants are excluded from the accessibility tree.
- They cannot be focused or clicked.
- Text selection and searching within the element are disabled.
We can manage this globally by selecting our application’s root element (e.g., #root) and setting its inert property whenever the modal is open.
useEffect(() => {
if (!isOpen) return;
const rootElement = document.getElementById('root');
if (rootElement) {
rootElement.setAttribute('inert', '');
}
return () => {
if (rootElement) {
rootElement.removeAttribute('inert');
}
};
}, [isOpen]);
This simple approach guarantees that screen reader users and keyboard navigators cannot accidentally tab out of the modal into the background application.
Step 3: Handling Focus Restoration
When a user clicks a “Open Settings” button to trigger a modal, their browser focuses on that button. When the modal closes, accessibility guidelines dictate that focus must return to that exact button so the user doesn’t lose their place on the page.
We can capture the active element when the modal opens and restore it upon unmount:
const previousActiveElementRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (isOpen) {
previousActiveElementRef.current = document.activeElement as HTMLElement;
} else if (previousActiveElementRef.current) {
previousActiveElementRef.current.focus();
}
}, [isOpen]);
Step 4: Implementing Focus Trapping
Focus trapping ensures that pressing Tab while on the last focusable element of a modal wraps focus back to the first focusable element, and vice versa for Shift + Tab.
Let’s write a utility to query all focusable elements inside our modal container:
const FOCUSABLE_SELECTORS = [
'a[href]',
'area[href]',
'input:not([disabled]):not([type="hidden"])',
'select:not([disabled])',
'textarea:not([disabled])',
'button:not([disabled])',
'iframe',
'object',
'embed',
'[contenteditable]',
'[tabindex]:not([tabindex="-1"])',
].join(',');
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isOpen) return;
const modalElement = modalRef.current;
if (!modalElement) return;
// Automatically focus the first focusable element on open
const focusableElements = modalElement.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (firstElement) {
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]);
Putting It All Together: The Complete Component
Here is the fully assembled, robust, TypeScript-ready Modal component combining portals, ARIA attributes, inert management, focus trapping, and keyboard event handling.
import React, { useEffect, useRef } from 'react';
import ReactDOM from 'react-dom';
interface AccessibleModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
const FOCUSABLE_SELECTORS = [
'a[href]',
'area[href]',
'input:not([disabled]):not([type="hidden"])',
'select:not([disabled])',
'textarea:not([disabled])',
'button:not([disabled])',
'iframe',
'object',
'embed',
'[contenteditable]',
'[tabindex]:not([tabindex="-1"])',
].join(',');
export const AccessibleModal: React.FC<AccessibleModalProps> = ({
isOpen,
onClose,
title,
children,
}) => {
const modalRef = useRef<HTMLDivElement>(null);
const previousActiveElementRef = useRef<HTMLElement | null>(null);
// 1. Manage Focus Restoration & Inert Background
useEffect(() => {
if (!isOpen) return;
previousActiveElementRef.current = document.activeElement as HTMLElement;
const rootElement = document.getElementById('root');
if (rootElement) {
rootElement.setAttribute('inert', '');
}
return () => {
if (rootElement) {
rootElement.removeAttribute('inert');
}
if (previousActiveElementRef.current) {
previousActiveElementRef.current.focus();
}
};
}, [isOpen]);
// 2. Manage Focus Trapping & Keyboard Events
useEffect(() => {
if (!isOpen) return;
const modalElement = modalRef.current;
if (!modalElement) return;
const focusableElements = modalElement.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (firstElement) {
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 ReactDOM.createPortal(
<div
className="modal-backdrop"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
className="modal-content"
>
<div className="modal-header">
<h2 id="modal-title">{title}</h2>
<button onClick={onClose} aria-label="Close modal dialog">
×
</button>
</div>
<div className="modal-body">{children}</div>
</div>
</div>,
document.body
);
};
Conclusion
Building accessible UI components requires intentional effort, but the payoff is an inclusive web application that works seamlessly for every user, regardless of their input device or assistive technology.
By combining semantic ARIA roles, the native inert attribute, precise focus trapping logic, and proper focus restoration, your React modal dialogs will meet and exceed modern accessibility standards.