Trapped in the DOM: Building a Bulletproof Accessible Modal in React
Learn how to build a production-ready, fully accessible modal dialog in React from scratch with robust focus trapping, keyboard navigation, and proper ARIA roles.
Trapped in the DOM: Building a Bulletproof Accessible Modal in React
Modals are one of the most common UI patterns in modern web applications. Yet, if you audit the average modal implementation across the web, you’ll likely find critical accessibility failures: screen reader users get lost in the background, keyboard users can tab right out of the dialog into nowhere, and pressing the Escape key does nothing.
Building an accessible modal isn’t just about adding a backdrop and a nice fade-in animation. It requires strict adherence to the WAI-ARIA Dialog Pattern, meticulous keyboard focus management, and shielding background content from assistive technologies.
In this walkthrough, we will build a production-ready, highly accessible modal component from scratch using React and TypeScript.
The Anatomy of an Accessible Modal
Before writing code, let’s review the requirements for a truly accessible modal dialog:
- ARIA Roles & Attributes: The dialog must use
role="dialog",aria-modal="true", and point to accessible names and descriptions viaaria-labelledbyandaria-describedby. - Focus Management on Open: When the modal opens, focus must instantly move to an interactive element inside the modal (preferably the first focusable element or the close button).
- Focus Trapping: Keyboard users (
TabandShift + Tab) must be trapped inside the modal. They should not be able to tab back to the background application. - Focus Restoration on Close: When the modal closes, focus must return precisely to the element that triggered it.
- Keyboard Support: Pressing the
Escapekey must close the modal. - Inert Background: Everything outside the modal must be hidden from screen readers and rendered inert.
Step 1: Setting up the TypeScript Interface
Let’s start by defining our component props. We need an isOpen flag, an onClose callback, a title, and children.
import React, { useEffect, useRef } from 'react';
export interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
Step 2: Handling ARIA Attributes and Escape Key
Next, we need to handle the Escape key listener and lock body scrolling. We also need to remember which element triggered the modal so we can restore focus later.
export const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children }) => {
const modalRef = useRef<HTMLDivElement>(null);
const previousActiveElementRef = useRef<HTMLElement | null>(null);
// Handle ESC key and Focus Restoration
useEffect(() => {
if (!isOpen) return;
// 1. Save current focus
previousActiveElementRef.current = document.activeElement as HTMLElement;
// 2. Add ESC key listener
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
};
document.addEventListener('keydown', handleKeyDown);
// Cleanup: restore focus when modal unmounts/closes
return () => {
document.removeEventListener('keydown', handleKeyDown);
if (previousActiveElementRef.current) {
previousActiveElementRef.current.focus();
}
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div className="modal-backdrop">
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
className="modal-container"
>
<h2 id="modal-title">{title}</h2>
<div className="modal-content">{children}</div>
<button onClick={onClose} aria-label="Close modal">
Close
</button>
</div>
</div>
);
};
Step 3: Implementing the Focus Trap
A focus trap ensures that when a user reaches the last focusable element in the modal and hits Tab, focus wraps around to the first focusable element. Conversely, Shift + Tab on the first element wraps to the last.
Let’s write a utility hook or direct logic to query focusable elements and intercept the Tab key.
useEffect(() => {
if (!isOpen || !modalRef.current) return;
const modalElement = modalRef.current;
// Find all focusable elements inside the modal
const focusableSelectors = [
'button:not([disabled])',
'[href]',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(',');
const focusableElements = modalElement.querySelectorAll<HTMLElement>(focusableSelectors);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
// Set initial focus
if (firstElement) {
firstElement.focus();
}
const handleTabKey = (event: KeyboardEvent) => {
if (event.key !== 'Tab') return;
if (event.shiftKey) {
// If shift + tab and focus is on first element, move to last
if (document.activeElement === firstElement) {
event.preventDefault();
lastElement?.focus();
}
} else {
// If tab and focus is on last element, move to first
if (document.activeElement === lastElement) {
event.preventDefault();
firstElement?.focus();
}
}
};
modalElement.addEventListener('keydown', handleTabKey);
return () => {
modalElement.removeEventListener('keydown', handleTabKey);
};
}, [isOpen]);
Step 4: Making the Background Inert
When a modal is open, assistive technologies (like screen readers) should not interact with the rest of the application. Modern browsers support the inert HTML attribute, which disables interaction and removes elements from the accessibility tree.
We can query the root application container (e.g., #root) and toggle the inert attribute when the modal opens and closes.
useEffect(() => {
if (!isOpen) return;
const rootElement = document.getElementById('root');
if (rootElement) {
rootElement.setAttribute('inert', 'true');
}
return () => {
if (rootElement) {
rootElement.removeAttribute('inert');
}
};
}, [isOpen]);
Note: If you are supporting older browsers that do not support
inertnatively, you can use thew3c-inertpolyfill to achieve the same background-shielding effect.
Putting It All Together
Here is the complete, compiled Modal component combining all accessibility requirements into a robust, reusable piece of UI.
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 previousActiveElementRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!isOpen) return;
previousActiveElementRef.current = document.activeElement as HTMLElement;
const rootElement = document.getElementById('root');
if (rootElement) rootElement.setAttribute('inert', 'true');
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
};
document.addEventListener('keydown', handleKeyDown);
const modalElement = modalRef.current;
if (modalElement) {
const focusableElements = modalElement.querySelectorAll<HTMLElement>(
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
firstElement?.focus();
const handleTabKey = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement?.focus();
}
} else {
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement?.focus();
}
}
};
modalElement.addEventListener('keydown', handleTabKey);
return () => {
modalElement.removeEventListener('keydown', handleTabKey);
};
}
return () => {
document.removeEventListener('keydown', handleKeyDown);
if (rootElement) rootElement.removeAttribute('inert');
if (previousActiveElementRef.current) {
previousActiveElementRef.current.focus();
}
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return ReactDOM.createPortal(
<div className="modal-backdrop">
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
className="modal-container"
>
<header className="modal-header">
<h2 id="modal-title">{title}</h2>
<button onClick={onClose} aria-label="Close modal">
✕
</button>
</header>
<div className="modal-body">{children}</div>
</div>
},
document.body
);
};
Conclusion
Accessibility is not an afterthought; it is a fundamental requirement of professional web development. By combining ReactDOM.createPortal, strict focus trapping, native inert background handling, and robust keyboard event listeners, you ensure that every user—regardless of ability or device—can seamlessly navigate your React application.
Test your modal today using only your keyboard (Tab, Shift + Tab, Escape) and confirm that focus never leaks into the background. Your users will thank you!