Trapped in the DOM: Building an Accessible Dialog and Modal Component in React
Learn how to build a production-ready, highly accessible modal dialog component in React using TypeScript, portals, focus trapping, and WAI-ARIA patterns.
Trapped in the DOM: Building an Accessible Dialog and Modal Component in React
Modals are ubiquitous in modern web applications. Whether you are prompting a user to confirm a dangerous deletion, collecting feedback via a multi-step form, or displaying a rich image preview, the modal dialog is a fundamental UI pattern.
Yet, if you inspect how modals are implemented across the web, you will frequently find them broken. Screen reader users get lost behind hidden layers, keyboard operators find their focus bleeding out into the background document, and background page scrolling continues unhindered, causing layout jumps.
Building a truly accessible modal requires adherence to the WAI-ARIA Authoring Practices Guide (APG). In this guide, we will build a robust, production-ready, accessible modal and dialog component in React using TypeScript, React Portals, and native DOM APIs—without heavy third-party UI libraries.
The Anatomy of an Accessible Modal
Before writing code, let’s establish what makes a modal accessible. According to the WAI-ARIA specification, a dialog must satisfy the following criteria:
- Semantic Role: The container must have
role="dialog"andaria-modal="true". - Accessible Labeling: It must be labeled via
aria-labelledby(pointing to a title element) oraria-label. - Focus Management: When the modal opens, focus must move immediately to an element inside the dialog (ideally the first interactive element or the close button).
- Focus Trapping: Keyboard focus must be cycled indefinitely within the modal. Pressing
Tabon the last focusable element must loop back to the first, andShift + Tabon the first must loop to the last. - Keyboard Dismissal: Pressing the
Escapekey must close the modal. - Background Inertness: Content outside the modal must be hidden from screen readers and rendered inert (
aria-hiddenorinert). - Scroll Locking: The background body element must not scroll while the modal is active.
- DOM Portaling: The modal must be rendered outside the standard React component hierarchy to prevent clipping issues caused by CSS stacking contexts (
z-indexandoverflow: hidden).
Step 1: Setting up the Portal Component
To break free of parent container constraints, we use ReactDOM.createPortal. Let’s create a utility component that safely mounts our modal children into a dedicated DOM node.
import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
interface PortalProps {
children: React.ReactNode;
containerId?: string;
}
export const Portal: React.FC<PortalProps> = ({
children,
containerId = 'modal-root'
}) => {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
let element = document.getElementById(containerId);
if (!element) {
element = document.createElement('div');
element.setAttribute('id', containerId);
document.body.appendChild(element);
}
return () => {
// Clean up dynamic container if it becomes empty
if (element && element.childElementCount === 0 && element.parentNode) {
element.parentNode.removeChild(element);
}
};
}, [containerId]);
if (!mounted) return null;
const portalNode = document.getElementById(containerId);
return portalNode ? createPortal(children, portalNode) : null;
};
Step 2: Implementing Focus Trapping
Focus trapping is often the trickiest part of modal development. When a user presses Tab, we need to intercept the event, query all focusable elements within the modal, and manually adjust document.activeElement if the boundaries are breached.
Here is a helper hook to handle focus trapping:
import { useEffect, useRef } from 'react';
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(',');
export function useFocusTrap(isActive: boolean) {
const containerRef = useRef<HTMLDivElement>(null);
const previousActiveElement = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!isActive) return;
// Save the element that had focus before the modal opened
previousActiveElement.current = document.activeElement as HTMLElement;
const container = containerRef.current;
if (!container) return;
// Focus the first focusable element inside the modal
const focusableElements = container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS);
const firstElement = focusableElements[0];
if (firstElement) {
firstElement.focus();
} else {
container.focus(); // Fallback if no interactive elements exist
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Tab') return;
const focusable = container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey) {
// Shift + Tab
if (document.activeElement === first) {
last.focus();
event.preventDefault();
}
} else {
// Tab
if (document.activeElement === last) {
first.focus();
event.preventDefault();
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
// Restore focus to the element that triggered the modal
if (previousActiveElement.current) {
previousActiveElement.current.focus();
}
};
}, [isActive]);
return containerRef;
}
Step 3: Managing Background Inertness and Body Scroll
When a modal is active, assistive technologies should ignore everything happening in the background. Modern browsers support the inert HTML attribute, which disables interaction and hides elements from the accessibility tree simultaneously.
We also need to prevent background scrolling by manipulating document.body.style.overflow.
import { useEffect } from 'react';
export function useModalEffects(isOpen: boolean) {
useEffect(() => {
if (!isOpen) return;
// 1. Lock background scroll
const originalOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
// 2. Set background content to inert
const rootElement = document.getElementById('root') || document.body.firstElementChild;
const originalInert = rootElement?.getAttribute('inert');
if (rootElement && rootElement.id !== 'modal-root') {
rootElement.setAttribute('inert', 'true');
}
return () => {
document.body.style.overflow = originalOverflow;
if (rootElement && rootElement.id !== 'modal-root') {
if (originalInert === null) {
rootElement.removeAttribute('inert');
} else {
rootElement.setAttribute('inert', originalInert);
}
}
};
}, [isOpen]);
}
Step 4: Putting It All Together in the Dialog Component
Now we combine our hooks and portal logic into a clean, reusable Modal component.
import React, { useEffect, useId } from 'react';
import { Portal } from './Portal';
import { useFocusTrap } from './useFocusTrap';
import { useModalEffects } from './useModalEffects';
import './Modal.css';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
description?: string;
}
export const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
children,
description
}) => {
const titleId = useId();
const descId = useId();
const containerRef = useFocusTrap(isOpen);
useModalEffects(isOpen);
// Handle Escape key dismissal
useEffect(() => {
if (!isOpen) return;
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
};
window.addEventListener('keydown', handleEscape);
return () => window.removeEventListener('keydown', handleEscape);
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<Portal>
<div
className="modal-backdrop"
onClick={onClose}
aria-hidden="true"
>
<div
ref={containerRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={description ? descId : undefined}
tabIndex={-1}
className="modal-container"
onClick={(e) => e.stopPropagation()} // Prevent backdrop click-through
>
<div className="modal-header">
<h2 id={titleId} className="modal-title">{title}</h2>
<button
type="button"
className="modal-close-btn"
onClick={onClose}
aria-label="Close modal"
>
×
</button>
</div>
{description && (
<p id={descId} className="modal-description">
{description}
</p>
)}
<div className="modal-content">
{children}
</div>
</div>
</div>
</Portal>
);
};
Step 5: Styling the Modal
An accessible modal still needs to look polished. Here is a baseline CSS layout ensuring high-contrast backdrop overlays and predictable positioning.
.modal-backdrop {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
padding: 1rem;
animation: fadeIn 0.2s ease-out;
}
.modal-container {
background-color: #ffffff;
border-radius: 8px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
width: 100%;
max-width: 500px;
padding: 1.5rem;
outline: none;
display: flex;
flex-direction: column;
gap: 1rem;
animation: scaleUp 0.2s ease-out;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.modal-title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: #111827;
}
.modal-close-btn {
background: transparent;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #6b7280;
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
.modal-close-btn:hover,
.modal-close-btn:focus-visible {
color: #111827;
background-color: #f3f4f6;
outline: 2px solid #2563eb;
}
.modal-description {
margin: 0;
color: #4b5563;
font-size: 0.875rem;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes scaleUp {
from { transform: scale(0.95); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
Testing Your Modal Implementation
Before deploying your new modal to production, verify it against these validation checkpoints:
- The Keyboard Test: Load your app, open the modal using a button click, and completely detach your mouse. Can you navigate every interactive element using only
TabandShift + Tab? Does focus ever escape into the background application? - The Escape Test: Does pressing
Escapeinstantly dismiss the dialog and return focus precisely to the element that triggered it? - The Screen Reader Audit: Turn on macOS VoiceOver (
Cmd + F5) or NVDA on Windows. Ensure the screen reader announces the dialog title, description, and role immediately upon opening, while ignoring background elements.
By taking the time to implement correct focus trapping, DOM portals, and native attributes like inert, you ensure that your web applications remain welcoming and usable for every single user.