Building an Accessible Modal in React: Focus Trapping, Inert Backgrounds, and ARIA Roles
A practical, step-by-step technical guide to building a production-ready modal dialog in React and TypeScript with focus trapping, inert backgrounds, and ARIA support.
Building a modal dialog seems simple at first glance: a fixed container, a backdrop, and a close button. However, making that modal truly accessible to screen readers, keyboard-only users, and people with cognitive or motor disabilities requires careful handling of focus states, keyboard events, semantic HTML, and background interactivity.
In this guide, we will build a production-ready, highly accessible modal component from scratch in React and TypeScript. We will cover ARIA roles, restoring focus upon closure, trapping keyboard focus within the modal, listening for the Escape key, and leveraging the modern native inert attribute to handle background content correctly.
The Anatomy of an Accessible Modal
To meet the Web Content Accessibility Guidelines (WCAG) and provide an intuitive experience for assistive technology users, a modal must fulfill several core requirements:
- Semantic Structure & ARIA: The dialog must be identified to screen readers using
role="dialog",aria-modal="true", and proper labeling viaaria-labelledbyandaria-describedby. - Focus Management on Open: When the modal opens, focus must shift immediately to an element inside the modal (usually the first interactive element or the container itself).
- Focus Trapping: Keyboard users (
TabandShift + Tab) must be restricted to cycling exclusively through interactive elements inside the modal. - Background Neutralization: Content outside the modal must be rendered non-interactive and hidden from assistive tech (using the native
inertattribute). - Escape-Key Dismissal: Pressing the
Escapekey must close the modal. - Focus Restoration: When the modal closes, focus must return precisely to the element that triggered the modal in the first place.
Let’s implement these requirements step-by-step.
Step 1: Setting up the TypeScript Interface
First, let’s define our component props. We need a way to control the open state, handle closure, and provide title information for screen readers.
import React, { useEffect, useRef, useState, ReactNode } from 'react';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: ReactNode;
triggerRef?: React.RefObject<HTMLElement | null>;
}
Step 2: Managing Focus Restoration and ARIA Roles
When a modal opens, we want to capture the active element (document.activeElement) so we can return focus to it when the modal closes. We also need to assign appropriate ARIA attributes.
export const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
children,
triggerRef,
}) => {
const modalRef = useRef<HTMLDivElement>(null);
const previousActiveElement = useRef<HTMLElement | null>(null);
useEffect(() => {
if (isOpen) {
// 1. Save the element that had focus before the modal opened
previousActiveElement.current = (document.activeElement as HTMLElement) || triggerRef?.current || null;
// 2. Focus the modal container or first focusable element
// Small timeout ensures the DOM has rendered the modal
const timer = setTimeout(() => {
modalRef.current?.focus();
}, 50);
return () => clearTimeout(timer);
} else {
// 3. Restore focus when the modal closes
previousActiveElement.current?.focus();
}
}, [isOpen, triggerRef]);
if (!isOpen) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabIndex={-1}
ref={modalRef}
className="modal-container"
>
<h2 id="modal-title">{title}</h2>
{children}
<button onClick={onClose}>Close</button>
</div>
);
};
Notice tabIndex={-1} on the modal container. This makes the div programmatically focusable via JavaScript without adding it to the natural keyboard tab stop order.
Step 3: Implementing Focus Trapping
Without a focus trap, pressing Tab will eventually cycle focus out of the modal and into the background webpage—a frustrating experience for keyboard users.
To trap focus, we query all focusable elements inside the modal when the user presses Tab. If they reach the last element and press Tab, we wrap focus back to the first element. If they press Shift + Tab on the first element, we wrap it to the last.
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
if (event.key === 'Tab' && modalRef.current) {
const focusableElements = modalRef.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (focusableElements.length === 0) return;
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (event.shiftKey) {
// Shift + Tab
if (document.activeElement === firstElement) {
lastElement.focus();
event.preventDefault();
}
} else {
// Tab
if (document.activeElement === lastElement) {
firstElement.focus();
event.preventDefault();
}
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [onClose]);
Step 4: Making the Background inert
Historically, developers relied on aria-hidden="true" combined with complex CSS styling or custom DOM traversals to prevent screen readers and pointer events from interacting with background content.
Today, modern browsers natively support the inert attribute. When an element is marked inert:
- It and all its descendants are excluded from the accessibility tree.
- It becomes un-clickable and un-focusable.
- Screen readers will completely ignore it.
We can apply this cleanly to our app root (or sibling elements of the modal):
useEffect(() => {
if (!isOpen) return;
// Assuming your main application wrapper has an id of 'root'
const rootElement = document.getElementById('root');
if (rootElement) {
rootElement.setAttribute('inert', '');
}
return () => {
if (rootElement) {
rootElement.removeAttribute('inert');
}
};
}, [isOpen]);
Note: If you target older browsers, make sure to include a polyfill for the
inertattribute (such asw3c/wicg-inert), though support in modern evergreen browsers is 100%.
Step 5: Putting It All Together
Here is the complete, integrated React component incorporating our ARIA attributes, focus restoration, event listeners for the escape key, focus trapping, and background inert handling.
import React, { useEffect, useRef, ReactNode } from 'react';
import ReactDOM from 'react-dom';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: ReactNode;
triggerRef?: React.RefObject<HTMLElement | null>;
}
export const AccessibleModal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
children,
triggerRef,
}) => {
const modalRef = useRef<HTMLDivElement>(null);
const previousActiveElement = useRef<HTMLElement | null>(null);
// Handle focus management and inert background
useEffect(() => {
if (isOpen) {
previousActiveElement.current =
(document.activeElement as HTMLElement) || triggerRef?.current || null;
const rootElement = document.getElementById('root');
if (rootElement) {
rootElement.setAttribute('inert', '');
}
const timer = setTimeout(() => {
modalRef.current?.focus();
}, 50);
return () => {
clearTimeout(timer);
if (rootElement) {
rootElement.removeAttribute('inert');
}
previousActiveElement.current?.focus();
};
}
}, [isOpen, triggerRef]);
// Handle keyboard events (ESC and Tab trap)
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
if (event.key === 'Tab' && modalRef.current) {
const focusableElements = modalRef.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (focusableElements.length === 0) return;
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (event.shiftKey) {
if (document.activeElement === firstElement) {
lastElement.focus();
event.preventDefault();
}
} else {
if (document.activeElement === lastElement) {
firstElement.focus();
event.preventDefault();
}
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen, onClose]);
if (!isOpen) return null;
// Use React Portals to render the modal at the end of the DOM tree
return ReactDOM.createPortal(
<div className="modal-backdrop">
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabIndex={-1}
className="modal-dialog"
>
<div className="modal-header">
<h2 id="modal-title">{title}</h2>
<button onClick={onClose} aria-label="Close modal">
×
</button>
</div>
<div className="modal-body">{children}</div>
</div>
</div>,
document.body
);
};
Conclusion
Building accessible user interfaces requires proactive design rather than afterthought patches. By combining proper ARIA roles, clean portal rendering, precise focus tracking, native inert backgrounds, and robust keyboard event management, you can create modal dialogs that provide an elite experience for every single user—regardless of how they navigate the web.