Trapped in the DOM: Building a Bulletproof Accessible Modal and Dialog in React
Master web accessibility by building a production-ready React modal using portals, custom hooks, focus trapping, and inert background management.
Trapped in the DOM: Building a Bulletproof Accessible Modal and Dialog in React
Modals and dialogs are ubiquitous in modern web applications. Whether they are used for confirming a destructive action, presenting a complex multi-step form, or displaying rich media, they represent a critical inflection point in user interaction.
Yet, despite their commonality, modals are among the most frequently misbuilt and inaccessible components on the web. A poorly implemented modal breaks screen reader navigation, traps keyboard-only users in infinite loops of frustration, or leaks focus into the background DOM.
In this installment of our accessibility-first UI series, we are going to build a production-ready, bulletproof modal dialog in React and TypeScript. We will leverage React Portals to render outside the main DOM hierarchy, construct a reusable custom hook for managing keyboard focus traps and focus restoration, and explore how to handle background inertness using modern browser APIs.
The Anatomy of an Accessible Dialog
Before writing any code, let’s establish the requirements for a fully accessible dialog based on the WAI-ARIA Authoring Practices Guide (APG):
- Semantic HTML: Use the native
<dialog>element where appropriate, or assign the proper ARIA roles (role="dialog",aria-modal="true"). - Focus Management on Open: When the modal opens, focus must instantly move to an interactive element inside the modal (ideally the first focusable element or a close button).
- The Focus Trap: Keyboard users (pressing
TaborShift + Tab) must cycle exclusively through the focusable elements inside the modal. Focus must never bleed into the background document. - Escape Key Dismissal: Pressing the
Escapekey must close the modal. - Focus Restoration on Close: When the modal closes, focus must return precisely to the element that triggered it (e.g., the button the user clicked to open the modal).
- Background Invalidation: Content outside the modal must be hidden from screen readers and rendered inert.
Step 1: Rendering via React Portals
Modals must break out of the standard DOM hierarchy to avoid clipping issues caused by parent elements with overflow: hidden, z-index stacking contexts, or CSS transforms. React Portals allow us to render children into a DOM node that exists outside the DOM hierarchy of the parent component.
Let’s create a portal wrapper component:
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 portalRoot = document.getElementById(containerId);
if (!portalRoot) {
portalRoot = document.createElement('div');
portalRoot.setAttribute('id', containerId);
document.body.appendChild(portalRoot);
}
return () => {
setMounted(false);
};
}, [containerId]);
if (!mounted) return null;
const portalRoot = document.getElementById(containerId);
return portalRoot ? createPortal(children, portalRoot) : null;
};
Step 2: Crafting the useFocusTrap Custom Hook
The core complexity of an accessible modal lies in managing keyboard navigation. We need a custom hook that intercepts Tab key presses, tracks focusable elements, remembers where the focus came from, and restores it upon unmounting.
Let’s design useFocusTrap in TypeScript:
import { useEffect, useRef } from 'react';
interface UseFocusTrapOptions {
isOpen: boolean;
onClose: () => void;
}
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({ isOpen, onClose }: UseFocusTrapOptions) {
const modalRef = useRef<HTMLDivElement | null>(null);
const previousActiveElement = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!isOpen) return;
// 1. Store the currently focused element to restore it later
previousActiveElement.current = document.activeElement as HTMLElement;
const modalElement = modalRef.current;
if (!modalElement) return;
// 2. Query all focusable elements inside the modal
const focusableElements = modalElement.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
// 3. Set initial focus
if (firstElement) {
firstElement.focus();
} else {
modalElement.focus();
}
// 4. Handle keyboard navigation & events
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
onClose();
return;
}
if (event.key === 'Tab') {
if (focusableElements.length === 0) {
event.preventDefault();
return;
}
if (event.shiftKey) {
// Shift + Tab
if (document.activeElement === firstElement) {
event.preventDefault();
lastElement?.focus();
}
} else {
// Tab
if (document.activeElement === lastElement) {
event.preventDefault();
firstElement?.focus();
}
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
// 5. Restore focus to the trigger element
if (previousActiveElement.current && typeof previousActiveElement.current.focus === 'function') {
previousActiveElement.current.focus();
}
};
}, [isOpen, onClose]);
return modalRef;
}
How the Hook Works:
- Snapshotting Focus: When
isOpenbecomes true,document.activeElementis captured in a ref. - Focus Trapping: A global
keydownlistener monitors forTab. If the user hitsTabwhile focused on the last focusable element, focus wraps around to the first element. Conversely,Shift + Tabwraps backward from the first to the last element. - Cleanup and Restoration: When the component unmounts or
isOpenbecomes false, focus is programmatically restored topreviousActiveElement.current, ensuring a seamless keyboard experience.
Step 3: Managing Background Inertness
Merely trapping focus inside a modal is insufficient; screen readers can still navigate to background content unless that content is explicitly hidden or marked as inert.
The modern HTML inert attribute makes this trivial. When an element has the inert attribute, the browser ignores it for user interactions and accessibility tree construction.
Let’s add a utility effect to apply inert to all siblings of our portal root:
useEffect(() => {
if (!isOpen) return;
const rootElement = document.getElementById('root');
const modalRoot = document.getElementById('modal-root');
// Apply inert to main application root
if (rootElement) {
rootElement.setAttribute('inert', '');
}
return () => {
if (rootElement) {
rootElement.removeAttribute('inert');
}
};
}, [isOpen]);
Note: For older browsers that do not support the
inertattribute natively, consider polyfilling it using the officialw3c/wicg-inertpackage.
Step 4: Putting It All Together in the Modal Component
Now let’s assemble our complete, accessible modal component combining portals, focus trapping, ARIA roles, and keyboard handlers.
import React, { useEffect } from 'react';
import { Portal } from './Portal';
import { useFocusTrap } from './useFocusTrap';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
export const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
children,
})
=> {
const modalRef = useFocusTrap({ isOpen, onClose });
// Prevent body scroll when modal is open
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'unset';
}
return () => {
document.body.style.overflow = 'unset';
};
}, [isOpen]);
if (!isOpen) return null;
return (
<Portal>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/50 transition-opacity z-40"
aria-hidden="true"
onClick={onClose}
/>
{/* Modal Dialog Container */}
<div className="fixed inset-0 flex items-center justify-center z-50 p-4">
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabIndex={-1}
className="bg-white rounded-lg shadow-xl max-w-md w-full p-6 outline-none focus:ring-2 focus:ring-blue-600"
>
<div className="flex items-center justify-between mb-4">
<h2 id="modal-title" className="text-xl font-semibold text-gray-900">
{title}
</h2>
<button
onClick={onClose}
aria-label="Close modal"
className="text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded p-1"
>
✕
</button>
</div>
<div className="text-gray-600 mb-6">
{children}
</div>
<div className="flex justify-end space-x-3">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-gray-500"
>
Cancel
</button>
<button
onClick={() => {
alert('Action confirmed!');
onClose();
}}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
Confirm
</button>
</div>
</div>
</div>
</Portal>
);
};
Step 5: Consuming the Modal in Practice
Using our modal is straightforward. Notice how we don’t have to write any manual focus management code inside our parent component—the custom hook and portal handle everything cleanly behind the scenes.
import React, { useState } from 'react';
import { Modal } from './Modal';
export function App() {
const [isModalOpen, setIsModalOpen] = useState(false);
return (
<main className="p-8">
<h1 className="text-3xl font-bold mb-4">Dashboard</h1>
<p className="text-gray-600 mb-6">
Manage your account settings and preferences.
</p>
<button
onClick={() => setIsModalOpen(true)}
className="px-4 py-2 bg-blue-650 text-white rounded-lg shadow hover:bg-blue-750 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
Open Confirmation Dialog
</button>
<Modal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
title="Are you sure?"
>
This action cannot be undone. This will permanently delete your account
and remove your data from our servers.
</Modal>
</main>
);
}
Testing Your Modal for Accessibility
Building accessibility-first components requires rigorous verification. Before shipping your modal to production, run through this quick QA checklist:
- Keyboard-Only Run: Unplug your mouse. Tab to the trigger button, press
Enter, and verify that focus immediately lands inside the modal (on the close button or first interactive element). - Tab Cycling: Press
Tabrepeatedly. Verify that focus cycles indefinitely between the modal’s internal interactive elements without ever escaping to the background document. - Escape Key: Press
Escapeand verify that the modal closes smoothly and focus snaps back instantly to the trigger button. - Screen Reader Audit: Use VoiceOver (macOS) or NVDA (Windows) to open the modal. Verify that the screen reader announces the dialog title (
aria-labelledby), ignores background elements (inert), and restricts navigation to the modal content (aria-modal="true").
Conclusion
Accessibility is not an afterthought or an optional feature flag; it is a fundamental pillar of robust software engineering. By combining React Portals, custom hooks for focus trapping and restoration, and modern HTML features like the inert attribute, we can build modal dialogs that are performant, delightful, and fully accessible to every user on the web.