Building an Accessible Modal in React: Focus Trapping, Portals, and ARIA Dialog Patterns
A practical, step-by-step tutorial on building a production-ready modal component from scratch in React, covering ReactDOM portals, focus trapping, and ARIA accessibility best practices.
Building an Accessible Modal in React: Focus Trapping, Portals, and ARIA Dialog Patterns
Modals are ubiquitous in modern web applications. Whether they are used for confirming a destructive action, presenting a multi-step form, or displaying a detailed image, modals require careful engineering to get right.
While rendering a box in the center of the screen feels simple, building a truly accessible modal requires solving several complex problems:
- DOM Hierarchy: How do we prevent CSS
overflowandz-indexstacking context issues from clipping our modal? - Keyboard Navigation: How do we trap keyboard focus inside the modal so screen reader and keyboard-only users don’t tab out into the background?
- Screen Readers: How do we communicate to assistive technologies that a dialog is open and the rest of the page is inert?
- UX Ergonomics: How do we handle Escape key presses and backdrop clicks intuitively?
In this tutorial, we will build a production-ready, accessible modal component from scratch using React, TypeScript, and ReactDOM Portals.
1. The Anatomy of an Accessible Dialog
Before writing code, let’s review the WAI-ARIA Authoring Practices Guide (APG) for a Modal Dialog. A compliant dialog must satisfy the following criteria:
- Role: The container must have
role="dialog"andaria-modal="true". - Labeling: It must be labeled via
aria-labelledby(pointing to a header ID) oraria-label. - Focus Management:
- When the modal opens, focus must move to an element inside the dialog (ideally the first interactive element or the close button).
- Focus must be trapped inside the modal while it is open.
- When the modal closes, focus must return to the element that triggered it.
- Interactivity: Pressing the
Escapekey must close the modal.
2. Component Architecture and Portal Rendering
First, we render modals using ReactDOM.createPortal. Without portals, our modal lives inside the DOM hierarchy of its parent component. If a parent has overflow: hidden, transform, or a low z-index, our modal will break visually.
Portals let us render the modal directly into the document.body while maintaining React state and context continuity.
Here is our TypeScript interface and component skeleton:
import React, { useEffect, useRef } from 'react';
import ReactDOM from 'react-dom';
export 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.create(
<div className="modal-overlay">
<div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
className="modal-content"
>
<h2 id="modal-title">{title}</h2>
<div className="modal-body">{children}</div>
<button onClick={onClose} aria-label="Close modal">
Close
</button>
</div>
</div>,
document.body
);
};
3. Managing Focus and Restoration
When a modal opens, we need to save a reference to the element that triggered it so we can restore focus to it upon closing. We also need to move focus into the modal immediately upon opening.
export const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
children,
}) => {
const previousActiveElementRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (isOpen) {
// 1. Save the currently focused element
previousActiveElementRef.value = document.activeElement as HTMLElement;
// Optionally focus the modal container or first focusable element here
} else {
// 2. Restore focus when modal closes
if (previousActiveElementRef.current) {
previousActiveElementRef.current.focus();
}
}
}, [isOpen]);
if (!isOpen) return null;
// ... return portal JSX
};
4. Implementing the Focus Trap
A focus trap prevents the user’s Tab and Shift + Tab keys from leaving the modal container. If a user tabs past the last focusable element in the modal, focus must cycle back to the first focusable element.
To achieve this, we query all focusable elements inside our modal ref on every keydown event.
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
if (event.key === 'Tab') {
const modalNode = modalRef.current;
if (!modalNode) return;
// Query all focusable elements
const focusableElements = modalNode.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (event.shiftKey) {
// Shift + Tab: if focus is on the first element, wrap to last
if (document.activeElement === firstElement) {
lastElement?.focus();
event.preventDefault();
}
} else {
// Tab: if focus is on the last element, wrap to first
if (document.activeElement === lastElement) {
firstElement?.focus();
event.preventDefault();
}
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen, onClose]);
5. Putting It Together: The Complete Modal Component
Here is the full, production-ready Modal component combining portals, focus restoration, escape key listeners, click-outside handling, and focus trapping.
import React, { useEffect, useRef } from 'react';
import ReactDOM from 'react-dom';
export interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
export const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
children,
}) => {
const modalRef = useRef<HTMLDivElement>(null);
const previousActiveElement = useRef<HTMLElement | null>(null);
useEffect(() => {
if (isOpen) {
previousActiveElement.current = document.activeElement as HTMLElement;
// Focus the modal wrapper or first element
setTimeout(() => {
const focusable = modalRef.current?.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (focusable && focusable.length > 0) {
focusable[0].focus();
} else {
modalRef.current?.focus();
}
}, 50);
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'unset';
previousActiveElement.current?.focus();
}
return () => {
document.body.style.overflow = 'unset';
};
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
if (e.key === 'Tab') {
const modal = modalRef.current;
if (!modal) return;
const focusableElements = modal.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 (e.shiftKey && document.activeElement === firstElement) {
lastElement.focus();
e.preventDefault();
} else if (!e.shiftKey && document.activeElement === lastElement) {
firstElement.focus();
e.preventDefault();
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen) return null;
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) {
onClose();
}
};
return ReactDOM.create(
<div className="modal-backdrop" onClick={handleOverlayClick}>
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabIndex={-1}
className="modal-container"
>
<header className="modal-header">
<h2 id="modal-title">{title}</h2>
<button onClick={onClose} aria-label="Close modal" className="modal-close-btn">
×
</button>
</header>
<div className="modal-body">
{children}
</div>
</div>
</div>,
document.body
);
};
6. Essential CSS Styles
To ensure our modal looks and feels correct, we need straightforward overlay and container styling:
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-container {
background: white;
padding: 2rem;
border-radius: 8px;
max-width: 500px;
width: 90%;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
outline: none;
position: relative;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.modal-close-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
}
Conclusion
Building an accessible modal in React involves more than just toggling CSS visibility. By combining ReactDOM Portals, proper ARIA attributes, strict focus trapping, and focus restoration, you ensure that users of all abilities—including keyboard-only and screen reader users—can navigate your application smoothly.
Feel free to drop this component into your design system or component library as a foundation, and extend it with animations, custom sizes, or footer action areas!