Trapped in the DOM: Building an Accessible Modal Dialog in React
A comprehensive, code-heavy guide to building a fully accessible modal dialog in React with focus trapping, ARIA attributes, and robust keyboard navigation.
Trapped in the DOM: Building an Accessible Modal Dialog in React
Building a modal dialog is one of the most common frontend tasks, yet it is surprisingly easy to get wrong from an accessibility (a11y) standpoint. A truly accessible modal isn’t just a box floating over a dimmed background; it requires meticulous management of keyboard focus, screen reader announcements, semantic ARIA attributes, and pointer events.
In this guide, we will build a production-ready, highly accessible modal dialog component in React using TypeScript. By the end of this post, you’ll understand how to implement focus trapping, handle the Escape key, restore focus upon closing, and properly wire up ARIA attributes without relying on heavy external UI libraries.
The Anatomy of an Accessible Modal
Before writing code, let’s review the core requirements outlined by the WAI-ARIA Authoring Practices Guide (APG) for Dialog (Modal):
- Semantic HTML & ARIA: The dialog container must have
role="dialog",aria-modal="true", and be labeled usingaria-labelledby(and optionallyaria-describedby). - Focus Management (Trapping): When the modal opens, focus must move inside it. Keyboard users (Tab / Shift+Tab) must not be able to navigate to elements outside the modal.
- Restoring Focus: When the modal closes, focus must return to the exact element that triggered it.
- Keyboard Interactions: Pressing the
Escapekey must close the modal. - Background Inertness: Content outside the modal should be hidden from assistive technologies and rendered inert.
Step 1: Setting up the TypeScript Interfaces
Let’s start by defining our component props. A robust modal needs to know if it’s open, a function to close it, an accessible title, and optional description IDs.
import React, { ReactNode } from 'react';
export interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: ReactNode;
/** Optional ID for a description element */
describedBy?: string;
}
Step 2: Implementing Focus Restoration
When a user clicks a button to open a modal, their focus is currently on that trigger button. When the modal closes, failing to restore focus forces screen reader and keyboard users to navigate all the way back from the top of the page.
We capture the active element immediately when the modal opens and restore it during cleanup.
import React, { useEffect, useRef } from 'react';
export const useRestoreFocus = (isOpen: boolean) => {
const previouslyFocusedElementRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (isOpen) {
// Store the currently focused element before the modal opens
previouslyFocusedElementRef.current = document.activeElement as HTMLElement;
} else {
// Restore focus when the modal closes
if (previouslyFocusedElementRef.current && typeof previouslyFocusedElementRef.current.focus === 'function') {
previouslyFocusedElementRef.current.focus();
}
}
}, [isOpen]);
};
Step 3: Engineering the Focus Trap
A focus trap prevents keyboard focus from escaping the modal boundaries. To achieve this, we query all focusable elements inside our modal container and listen for the Tab key.
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 useFocusTrap = (isOpen: boolean, modalRef: React.RefObject<HTMLDivElement>) => {
useEffect(() => {
if (!isOpen || !modalRef.current) return;
const modalElement = modalRef.current;
// Automatically focus the first focusable element inside the modal upon opening
const focusableElements = modalElement.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (firstElement) {
firstElement.focus();
} else {
// Fallback: focus the modal container itself if no interactive elements exist
modalElement.focus();
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Tab') return;
const currentFocusable = modalElement.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS);
const first = currentFocusable[0];
const last = currentFocusable[currentFocusable.length - 1];
if (event.shiftKey) {
// If Shift + Tab and focus is on the first element, wrap to the last element
if (document.activeElement === first) {
last?.focus();
event.preventDefault();
}
} else {
// If Tab and focus is on the last element, wrap to the first element
if (document.activeElement === last) {
first?.focus();
event.preventDefault();
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen, modalRef]);
};
Step 4: Putting It All Together in the Modal Component
Now, let’s assemble our hooks, ARIA attributes, and event listeners into a complete React component.
import React, { useRef, useId, useEffect } from 'react';
import { ModalProps } from './types';
import { useRestoreFocus } from './useRestoreFocus';
import { useFocusTrap } from './useFocusTrap';
export const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
children,
describedBy,
}) => {
const modalRef = useRef<HTMLDivElement>(null);
const titleId = useId();
// Handle focus lifecycle
useRestoreFocus(isOpen);
useFocusTrap(isOpen, modalRef);
// Handle ESC key to close
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
// Prevent background scrolling when modal is open
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [isOpen]);
if (!isOpen) return null;
return (
<div className="modal-backdrop" onClick={onClose}>
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={describedBy}
tabIndex={-1}
className="modal-container"
onClick={(e) => e.stopPropagation()} // Prevent backdrop click from closing when clicking modal body
>
<header className="modal-header">
<h2 id={titleId} className="modal-title">
{title}
</h2>
<button
type="button"
onClick={onClose}
aria-label="Close modal"
className="modal-close-button"
>
×
</button>
</header>
<div className="modal-body">
{children}
</div>
</div>
</div>
);
};
Step 5: Essential CSS Styling
A good accessibility implementation should be accompanied by clear visual states. Here is a baseline stylesheet to ensure proper positioning, contrast, and layout.
.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 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
display: flex;
flex-direction: column;
outline: none; /* Focus outline managed via custom styles or container purpose */
}
.modal-container:focus-visible {
box-shadow: 0 0 0 3px #2563eb, 0 20px 25px -5px rgba(0, 0, 0, 0.1);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.modal-title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: #111827;
}
.modal-close-button {
background: transparent;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #6b7280;
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
.modal-close-button:hover,
.modal-close-button:focus-visible {
color: #111827;
background-color: #f3f4f6;
outline: 2px solid #2563eb;
}
.modal-body {
padding: 1.5rem;
color: #4b5563;
}
Testing Your Implementation
Before deploying your modal component to production, perform these manual accessibility audits:
- The Keyboard-Only Test: Unplug your mouse or trackpad. Click your trigger button via keyboard (
EnterorSpace), pressTabrepeatedly to ensure focus stays exclusively within the modal, and hitEscapeto close it. - Screen Reader Verification: Test the component with a screen reader like VoiceOver (macOS) or NVDA (Windows). Confirm that when the modal opens, the screen reader announces the title immediately, identifies it as a dialog, and ignores background elements.
- DOM Inspection: Check that
aria-modal="true"is present and thataria-labelledbypoints to a valid heading ID.
Conclusion
Building accessible components requires deliberate planning beyond visual UI design. By managing focus traps, keeping track of previous active elements, and leveraging proper WAI-ARIA roles, you ensure that every user—regardless of ability or input device—can successfully navigate your React application.