Building an Accessible Modal Dialog in React: Focus Trapping, Portals, and ARIA Patterns
A practical, step-by-step guide to building a production-ready modal dialog in React and TypeScript with robust accessibility features including focus trapping, portal rendering, and ARIA attributes.
Building an Accessible Modal Dialog in React: Focus Trapping, Portals, and ARIA Patterns
Modal dialogs are one of the most common UI components in modern web applications, yet they are notoriously difficult to implement correctly from an accessibility (a11y) standpoint. A poorly built modal can trap screen reader users, allow focus to bleed into background content, ignore keyboard navigation, and disrupt the natural DOM flow.
To build a truly production-ready modal in React and TypeScript, we need to solve three core technical challenges:
- Rendering context: Escaping the local DOM tree using React Portals to prevent z-index and clipping issues.
- Accessibility semantics: Applying proper WAI-ARIA roles, states, and properties.
- Keyboard interaction and focus management: Trapping focus within the modal and restoring it when closed.
Let’s dive into building a fully accessible, robust modal component from scratch.
1. The Architectural Blueprint
Before writing code, let’s establish what makes a modal accessible according to the WAI-ARIA Authoring Practices Guide (APG):
- Role & Labelling: The container must have
role="dialog",aria-modal="true", and an accessible name (aria-labelledbyoraria-label). - Focus Management: When opened, focus must move inside the modal immediately. When closed, focus must return to the element that triggered the modal.
- Focus Trap: Tabbing forward or backward must cycle exclusively through the interactive elements inside the modal.
- Dismissal: Pressing the
Escapekey must close the modal. - Background Inertness: Content outside the modal should be hidden from assistive technologies.
2. Setting Up the TypeScript Interface and Portal
We will start by defining our component’s props and implementing the React Portal wrapper. Using ReactDOM.createPortal ensures our modal renders at the DOM root (typically directly inside document.body), avoiding clipping contexts created by CSS properties like overflow: hidden or transform on parent containers.
import React, { useEffect, useRef } from 'react';
import ReactDOM from 'react-dom';
export interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
ariaDescribedBy?: string;
}
export const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
children,
ariaDescribedBy,
}) => {
if (!isOpen) return null;
return ReactDOM.createPortal(
<div className="modal-overlay">
{/* Modal content goes here */}
</div>,
document.body
);
};
3. Implementing Focus Trapping
Focus trapping ensures that keyboard users (using the Tab and Shift + Tab keys) cannot navigate outside of the modal dialog while it is open.
To achieve this, we query all focusable elements within our modal container and intercept the keydown event on the Tab key to loop focus from the last element back to the first, and vice versa.
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(',');
Inside our component, we capture the element that triggered the modal so we can restore focus to it upon closing, and attach our keyboard event listeners:
export const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
children,
ariaDescribedBy,
}) => {
const modalRef = useRef<HTMLDivElement>(null);
const previousActiveElement = useRef<HTMLElement | null>(null);
useEffect(() => {
if (isOpen) {
// 1. Save current active element
previousActiveElement.current = document.activeElement as HTMLElement;
// 2. Focus the modal container or first focusable element
const modalElement = modalRef.current;
if (modalElement) {
const focusableElements = modalElement.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS);
if (focusableElements.length > 0) {
focusableElements[0].focus();
} else {
modalElement.focus();
}
}
// 3. Handle keydown events (Escape and Tab trapping)
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
if (event.key === 'Tab' && modalElement) {
const focusableElements = Array.from(
modalElement.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS)
);
if (focusableElements.length === 0) {
event.preventDefault();
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);
// Cleanup: restore focus and remove listener
return () => {
document.removeEventListener('keydown', handleKeyDown);
if (previousActiveElement.current) {
previousActiveElement.current.focus();
}
};
}
}, [isOpen, onClose]);
if (!isOpen) return null;
// ... render method
};
4. Applying ARIA Attributes and Structure
Now we integrate our ref and ARIA attributes into the JSX structure. We use a unique ID for the title to satisfy aria-labelledby, ensuring screen readers announce the modal’s purpose immediately upon opening.
const titleId = useRef(`modal-title-${Math.random().toString(36).substr(2, 9)}`).current;
return ReactDOM.createPortal(
<div className="modal-backdrop" onClick={onClose}>
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={ariaDescribedBy}
tabIndex={-1}
className="modal-container"
onClick={(e) => e.stopPropagation()} // Prevent backdrop click from closing when clicking inside
>
<header className="modal-header">
<h2 id={titleId} className="modal-title">
{title}
</h2>
<button
type="button"
onClick={onClose}
aria-label="Close modal"
className="modal-close-btn"
>
×
</button>
</header>
<div className="modal-body">
{children}
</div>
</div>
</div>,
document.body
);
Pro-Tip on Backdrop Clicks: Notice
onClick={(e) => e.stopPropagation()}on the inner container. This ensures that clicking inside the modal box doesn’t inadvertently trigger the backdrop’s click-to-close handler, while clicking outside on the backdrop still closes the modal.
5. Making the Background Inert
Screen readers and keyboard navigation can still interact with background content if it remains in the accessibility tree, even if visually covered by a backdrop. Modern browsers natively support the inert attribute, which disables interaction and removes elements from the accessibility tree.
We can enhance our component to toggle inert on the root application element while the modal is open:
useEffect(() => {
if (isOpen) {
const rootElement = document.getElementById('root');
if (rootElement) {
rootElement.setAttribute('inert', 'true');
}
return () => {
if (rootElement) {
rootElement.removeAttribute('inert');
}
};
}
}, [isOpen]);
(Note: For older browser support, you can polyfill inert or manually toggle aria-hidden="true" on all sibling nodes of the portal container). cref
6. Styling the Modal
An accessible modal also requires clear visual states, particularly high-contrast focus rings for keyboard users. Here is standard CSS to style the backdrop and container:
.modal-backdrop {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-container {
background: #ffffff;
padding: 2rem;
border-radius: 8px;
width: 100%;
max-width: 500px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
outline: none;
}
.modal-container:focus-visible {
ring: 2px solid #2563eb;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.modal-close-btn {
background: transparent;
border: none;
font-size: 1.5rem;
cursor: pointer;
padding: 0.25rem 0.5rem;
}
.modal-close-btn:focus-visible {
outline: 2px solid #2563eb;
border-radius: 4px;
}
Conclusion
Building an accessible modal in React requires careful attention to detail beyond just styling a box in the center of the screen. By combining React Portals for correct DOM placement, rigorous focus management and trapping for keyboard navigators, robust ARIA attributes for screen readers, and the inert attribute for background isolation, you create a component that provides a seamless, inclusive experience for every user.
Always test your modals using only a keyboard (Tab, Shift+Tab, Escape) and a screen reader (such as VoiceOver on macOS or NVDA on Windows) to verify that your implementation meets real-world accessibility standards.