Bulletproof Modals: Building an Accessible Dialog in React
Learn how to build a production-ready, fully accessible modal dialog in React and TypeScript from scratch, featuring focus trapping, ARIA attributes, and escape-key handling.
Bulletproof Modals: Building an Accessible Dialog in React
Modal dialogs are a staple of modern web applications. Whether you are prompting a user to confirm a dangerous deletion, gathering input via a form, or displaying detailed media, modals demand the user’s immediate attention.
However, building a truly robust modal is deceptively difficult. If you rely on a simple fixed-position <div> with an absolute close button, you are likely failing users who rely on keyboards, screen readers, or assistive technologies.
In this guide, we will build a production-ready, fully accessible modal dialog system from scratch using React and TypeScript, completely avoiding heavy third-party UI libraries. We will cover:
- Semantic HTML and WAI-ARIA authoring practices.
- Managing focus and implementing a robust focus trap.
- Handling global keyboard events (
Escapeto close). - Restoring focus to the triggering element upon dismissal.
- Preventing background scrolling.
The Core Requirements of an Accessible Modal
Before writing code, let’s establish what makes a modal accessible according to the WAI-ARIA Modal Dialog Pattern:
- Role & Attributes: The container must have
role="dialog",aria-modal="true", and an accessible name (aria-labelledbyoraria-label). - Focus Management: When the modal opens, focus must move inside the modal immediately. While open, focus must remain trapped inside the modal (users shouldn’t tab out into the background content).
- Keyboard Interaction: Pressing the
Escapekey must close the modal. - Focus Restoration: When the modal closes, focus must return to the exact element that triggered it.
- Inert Background: Content behind the modal should be hidden from screen readers and rendered inert.
Step 1: Setting Up the Types and Component Shell
Let’s start by defining our TypeScript interface. Our modal needs to accept an isOpen boolean, an onClose callback, a title, and children.
import React, { useEffect, useRef } from 'react';
import ReactDOM from 'react-dom';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
We will use React Portals (ReactDOM.createPortal) to render the modal at the end of the document.body. This prevents z-index stacking context issues and CSS clipping from parent containers.
export const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children }) => {
if (!isOpen) return null;
return ReactDOM.createPortal(
<div className="modal-overlay">
<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">
×
</button>
{children}
</div>
</div>,
document.body
);
};
Step 2: Restoring Focus on Close
When a user clicks a “Delete Account” button to open a modal, their focus is on that button. Once the modal closes, their focus shouldn’t evaporate or jump arbitrarily to the top of the page. It must return to the element that triggered it.
We achieve this by storing a reference to document.activeElement when the modal mounts, and restoring focus when it unmounts.
export const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children }) => {
const previousActiveElement = useRef<HTMLElement | null>(null);
useEffect(() => {
if (isOpen) {
// Store the element that had focus before the modal opened
previousActiveElement.current = document.activeElement as HTMLElement;
} else {
// Restore focus when modal closes
if (previousActiveElement.current) {
previousActiveElement.current.focus();
}
}
}, [isOpen]);
if (!isOpen) return null;
// ... portal render
};
Step 3: Handling the Escape Key
Users expect to press Escape to dismiss overlays. We can listen for keydown events globally on the window while the modal is open.
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
};
if (isOpen) {
window.addEventListener('keydown', handleKeyDown);
}
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen, onClose]);
Step 4: Implementing the Focus Trap
A focus trap ensures that pressing Tab or Shift + Tab cycles through only the interactive elements inside the modal. Without this, a keyboard user would tab out of the modal and navigate invisible background elements.
First, let’s create a helper to query all focusable elements within our modal container:
const FOCUSABLE_ELEMENTS = [
'a[href]',
'area[href]',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'button:not([disabled])',
'iframe',
'object',
'embed',
'[contenteditable]',
'[tabindex]:not([tabindex="-1"])',
].join(',');
Now, inside our component, we attach a ref to the modal content wrapper and intercept the Tab key:
export const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children }) => {
const modalRef = useRef<HTMLDivElement>(null);
const previousActiveElement = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!isOpen) return;
previousActiveElement.current = document.activeElement as HTMLElement;
const modalElement = modalRef.current;
if (modalElement) {
// Focus the first focusable element inside the modal upon opening
const focusableElements = modalElement.querySelectorAll<HTMLElement>(FOCUSABLE_ELEMENTS);
if (focusableElements.length > 0) {
focusableElements[0].focus();
}
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
if (event.key === 'Tab' && modalElement) {
const focusableElements = modalElement.querySelectorAll<HTMLElement>(FOCUSABLE_ELEMENTS);
if (focusableElements.length === 0) return;
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
// Shift + Tab on the first element -> wrap around to the last
if (event.shiftKey && document.activeElement === firstElement) {
lastElement.focus();
event.preventDefault();
}
// Tab on the last element -> wrap around to the first
else if (!event.shiftKey && document.activeElement === lastElement) {
firstElement.focus();
event.preventDefault();
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
if (previousActiveElement.current) {
previousActiveElement.current.focus();
}
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return ReactDOM.createPortal(
<div className="modal-overlay">
<div
ref={modalRef}
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">
×
</button>
<div className="modal-body">{children}</div>
</div>
</div>,
document.body
);
};
Step 5: Preventing Background Scrolling
When a modal is open, scrolling the background page can be disorienting. We can easily lock body scrolling using useEffect:
useEffect(() => {
if (isOpen) {
const originalOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = originalOverflow;
};
}
}, [isOpen]);
Putting It All Together
Here is the complete, cohesive implementation of our bulletproof modal component:
import React, { useEffect, useRef } from 'react';
import ReactDOM from 'react-dom';
import './Modal.css';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
const FOCUSABLE_SELECTORS = [
'a[href]',
'area[href]',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'button:not([disabled])',
'iframe',
'object',
'embed',
'[contenteditable]',
'[tabindex]:not([tabindex="-1"])',
].join(',');
export const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children }) => {
const modalRef = useRef<HTMLDivElement>(null);
const previousActiveElement = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!isOpen) return;
previousActiveElement.current = document.activeElement as HTMLElement;
const originalOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const modalElement = modalRef.current;
if (modalElement) {
const focusable = modalElement.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS);
if (focusable.length > 0) {
focusable[0].focus();
}
}
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
if (e.key === 'Tab' && modalElement) {
const focusable = modalElement.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
last.focus();
e.preventDefault();
} else if (!e.shiftKey && document.activeElement === last) {
first.focus();
e.preventDefault();
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = originalOverflow;
if (previousActiveElement.current) {
previousActiveElement.current.focus();
}
};
}, [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"
className="modal-container"
onClick={(e) => e.stopPropagation()} // Prevent click-through closing
>
<div className="modal-header">
<h2 id="modal-title" className="modal-title">{title}</h2>
<button className="modal-close-btn" onClick={onClose} aria-label="Close modal">
✕
</button>
</div>
<div className="modal-body">
{children}
</div>
</div>
</div>,
document.body
);
};
Styling for Success
To ensure the modal looks correct and feels grounded, add accompanying CSS that handles backdrop blurring, fixed positioning, and smooth transitions:
.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;
padding: 1rem;
}
.modal-container {
background: #ffffff;
border-radius: 8px;
width: 100%;
max-width: 500px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
display: flex;
flex-direction: column;
max-height: 90vh;
outline: none;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem;
border-bottom: 1px solid #e5e7eb;
}
.modal-title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
}
.modal-close-btn {
background: transparent;
border: none;
font-size: 1.25rem;
cursor: pointer;
padding: 0.25rem 0.5rem;
color: #6b7280;
}
.modal-close-btn:hover {
color: #111827;
}
.modal-body {
padding: 1.25rem;
overflow-y: auto;
}
Conclusion
Building custom components from scratch gives you ultimate control over bundle size, styling, and behavior. By integrating proper ARIA attributes, focus trapping, escape-key handlers, and focus restoration, you ensure that your modal works seamlessly for every user, regardless of whether they use a mouse, a keyboard, or a screen reader.
Happy coding!