Modal Dialogs Done Right: Accessible Overlays in React
Learn how to build a fully accessible, WCAG-compliant modal dialog in React and TypeScript featuring focus trapping, keyboard dismissal, and inert background content.
Modal Dialogs Done Right: Accessible Overlays in React
Modal dialogs are one of the most common UI patterns in modern web applications. Whether you are building a confirmation prompt, a complex form wizard, or an image lightbox, modals demand strict attention to detail.
Yet, if you test standard modal implementations against screen readers and keyboard-only navigation, many fall short. Without proper accessibility (a11y) considerations, keyboard users can find themselves trapped outside the modal while focus wanders into the background, or screen reader users may remain entirely unaware that a popup has appeared.
In this guide, we will build a production-ready, highly accessible modal dialog component in React using TypeScript. We will tackle the foundational pillars of modal accessibility:
- Semantic markup using proper ARIA attributes (
aria-modal,role="dialog", etc.). - Keyboard dismissal via the
Escapekey. - Focus trapping to keep keyboard focus confined within the modal boundaries.
- Focus restoration to return the user’s cursor to the triggering element upon closure.
- Background isolation using the native
inertattribute.
The Anatomy of an Accessible Modal
Before diving into code, let’s establish what makes a modal truly accessible according to the WAI-ARIA Authoring Practices Guide (APG):
- Role: The container must have
role="dialog"andaria-modal="true". - Labeling: It must have an accessible name, typically linked via
aria-labelledbypointing to the modal title, oraria-label. - Initial Focus: When the modal opens, focus must move immediately to an element inside the modal (usually the first focusable element or the container itself).
- Focus Trap: Pressing
TaborShift + Tabmust cycle through focusable elements only inside the modal. - Escape Key: Pressing
Escapemust close the modal. - Background Inertness: Content outside the modal should be hidden from assistive technologies and made un-interactive.
Let’s see how we implement this step by step in React and TypeScript.
Step 1: The TypeScript Interfaces and Base Component
Let’s start by defining our component props. We need props to control open/closed states, trigger dismissal, and provide accessibility labels.
import React, { useEffect, useRef, ReactNode } from 'react';
import ReactDOM from 'react-dom';
export interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: ReactNode;
initialFocusRef?: React.RefObject<HTMLElement>;
}
Next, let’s construct the skeleton of our component using React Portals. Portals allow us to render the modal outside the main DOM hierarchy, preventing clipping issues caused by CSS overflow: hidden or z-index stacking contexts.
export const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
children,
initialFocusRef,
}) => {
const modalRef = useRef<HTMLDivElement>(null);
const previousActiveElement = useRef<HTMLElement | null>(null);
if (!isOpen) return null;
return ReactDOM.createPortal(
<div className="modal-backdrop" onClick={onClose}>
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
className="modal-content"
onClick={(e) => e.stopPropagation()}
>
<h2 id="modal-title" className="modal-title">
{title}
</h2>
<div className="modal-body">{children}</div>
<button onClick={onClose} aria-label="Close modal">
Close
</button>
</div>
</div>,
document.body
);
};
Step 2: Restoring Focus and Managing the Trigger
When a modal opens, we must record the element that triggered it (e.g., a button) so we can restore focus to it when the modal closes. If we fail to do this, screen reader and keyboard users are dropped back to the top of the document (<body>), forcing them to re-navigate the entire page.
We achieve this using useEffect hooks to capture and restore focus.
useEffect(() => {
if (isOpen) {
// 1. Save the currently focused element
previousActiveElement.current = document.activeElement as HTMLElement;
// 2. Set initial focus inside the modal
if (initialFocusRef?.current) {
initialFocusRef.current.focus();
} else if (modalRef.current) {
// Fallback: focus the modal container or first focusable element
const focusableElements = getFocusableElements(modalRef.current);
if (focusableElements.length > 0) {
focusableElements[0].focus();
} else {
modalRef.current.focus();
}
}
}
// 3. Cleanup: restore focus when modal unmounts or closes
return () => {
if (previousActiveElement.current) {
previousActiveElement.current.focus();
}
};
}, [isOpen, initialFocusRef]);
Step 3: Implementing the Focus Trap
If a user presses Tab while the last focusable element inside the modal is active, focus would normally escape into the browser UI or background document. To prevent this, we intercept keyboard events and manually redirect focus.
First, let’s write a utility function to query all focusable elements within a given 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(',');
function getFocusableElements(container: HTMLElement): HTMLElement[] {
const elements = container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS);
return Array.from(elements).filter(
(el) => el.offsetWidth > 0 || el.offsetHeight > 0 || el === document.activeElement
);
}
Now, inside our component, we listen for keydown events and manage the tab loop:
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (!modalRef.current) return;
// Handle Escape Key Dismissal
if (event.key === 'Escape') {
event.stopPropagation();
onClose();
return;
}
// Handle Focus Trap on Tab Key
if (event.key === 'Tab') {
const focusableElements = getFocusableElements(modalRef.current);
if (focusableElements.length === 0) return;
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (event.shiftKey) {
// Shift + Tab: if focused on first element, wrap to last
if (document.activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
}
} else {
// Tab: if focused on last element, wrap to first
if (document.activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [onClose]);
Step 4: Isolating Background Content with the inert Attribute
Historically, hiding background content from screen readers and pointer devices while a modal was open required complex logic involving aria-hidden="true" applied to all sibling elements of the modal root, or managing manual click blockers.
Today, modern browsers natively support the inert attribute. When an element is marked as inert:
- It and all its descendants are hidden from accessibility trees.
- It ignores click and touch events.
- It is removed from the tab order.
We can easily apply this to our root application container (e.g., #root) when the modal opens:
useEffect(() => {
if (!isOpen) return;
const rootElement = document.getElementById('root');
if (rootElement) {
rootElement.setAttribute('inert', '');
}
return () => {
if (rootElement) {
rootElement.removeAttribute('inert');
}
};
}, [isOpen]);
Note: For older browser fallbacks, ensure critical content outside your portal wrapper utilizes
aria-hidden="true", thoughinertis now widely supported across all modern evergreen browsers.
Putting It All Together
Here is the complete, cohesive TypeScript implementation of our accessible modal component:
import React, { useEffect, useRef, ReactNode } from 'react';
import ReactDOM from 'react-dom';
import './Modal.css';
export interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: ReactNode;
initialFocusRef?: React.RefObject<HTMLElement>;
}
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(',');
function getFocusableElements(container: HTMLElement): HTMLElement[] {
const elements = container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS);
return Array.from(elements).filter(
(el) => el.offsetWidth > 0 || el.offsetHeight > 0 || el === document.activeElement
);
}
export const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
children,
initialFocusRef,
}) => {
const modalRef = useRef<HTMLDivElement>(null);
const previousActiveElement = useRef<HTMLElement | null>(null);
// Manage Focus Restoration
useEffect(() => {
if (isOpen) {
previousActiveElement.current = document.activeElement as HTMLElement;
if (initialFocusRef?.current) {
initialFocusRef.current.focus();
} else if (modalRef.current) {
const focusableElements = getFocusableElements(modalRef.current);
if (focusableElements.length > 0) {
focusableElements[0].focus();
} else {
modalRef.current.focus();
}
}
}
return () => {
if (previousActiveElement.current) {
previousActiveElement.current.focus();
}
};
}, [isOpen, initialFocusRef]);
// Manage Inert Background Content
useEffect(() => {
if (!isOpen) return;
const rootElement = document.getElementById('root');
if (rootElement) {
rootElement.setAttribute('inert', '');
}
return () => {
if (rootElement) {
rootElement.removeAttribute('inert');
}
};
}, [isOpen]);
// Manage Keydown Listeners (Escape & Tab Trap)
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (!modalRef.current) return;
if (event.key === 'Escape') {
event.stopPropagation();
onClose();
return;
}
if (event.key === 'Tab') {
const focusableElements = getFocusableElements(modalRef.current);
if (focusableElements.length === 0) return;
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (event.shiftKey) {
if (document.activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
}
} else {
if (document.activeElement === lastElement) {
event.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={onClose}>
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabIndex={-1}
className="modal-content"
onClick={(e) => e.stopPropagation()}
>
<header className="modal-header">
<h2 id="modal-title" className="modal-heading">
{title}
</h2>
<button
type="button"
onClick={onClose}
className="modal-close-btn"
aria-label="Close modal"
>
×
</button>
</header>
<div className="modal-body">{children}</div>
</div>
</div>,
document.body,
);
};
Conclusion
Building an accessible modal dialog requires going beyond basic CSS positioning and click handlers. By implementing proper ARIA attributes, robust focus trapping, seamless focus restoration, native inert background isolation, and keyboard listeners for the Escape key, you guarantee an inclusive experience for all users.
While writing custom hooks and wrapper components provides deep insight into web accessibility standards, if you are looking for battle-tested, highly accessible headless UI primitives for larger design systems, consider exploring libraries like Radix UI, Headless UI, or React Aria which handle these edge cases out of the box.