Trapped in the DOM: Building a Bulletproof Accessible Modal in React
Learn how to build a production-ready, accessible React modal from scratch using TypeScript, handling focus trapping, screen reader announcements, and scroll locking without third-party libraries.
Trapped in the DOM: Building a Bulletproof Accessible Modal in React
Modals are ubiquitous in modern web development. Whether you are building a critical confirmation dialog, a complex multi-step form, or a media lightbox, modals are essential for focused user interaction.
However, if you test a typical modal built with a naive <div> and absolute positioning against screen readers or keyboard navigation, you will quickly discover how fragile it is. Users can tab out of the modal into the background content, background pages continue to scroll, screen readers read out obscured page content, and pressing the Escape key does nothing.
In this guide, we will build a production-ready, highly accessible modal component in React and TypeScript from scratch. We will cover:
- WAI-ARIA Dialog Pattern compliance
- Imperative focus management and focus trapping
- Preventing background scrolling
- Utilizing the modern
inertattribute - Zero third-party bloat
The Anatomy of an Accessible Modal
Before writing code, let’s establish what makes a modal truly accessible. According to the WAI-ARIA Authoring Practices Guide (APG), a dialog (modal) must adhere to these foundational rules:
- Semantic Structure: It must use the correct roles (
role="dialog"), aria attributes (aria-modal="true"), and label associations (aria-labelledbyandaria-describedby). - Focus Management: When the modal opens, focus must move immediately to an element inside the modal. When the modal closes, focus must return to the trigger element that opened it.
- Focus Trapping: Keyboard users (using the
Tabkey) must not be able to navigate outside the modal container while it is open. - Keyboard Dismissal: Pressing the
Escapekey must close the modal. - Background Inertness: Content outside the modal must be hidden from screen readers and removed from the keyboard navigation flow.
Step 1: Component Interface and Setup
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';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
Step 2: Managing Focus and the Escape Key
Focus management is often where custom modals fail. When a modal opens, we need to save a reference to the element that currently has focus (the trigger button). When the modal closes, we return focus to that element.
Inside the modal, we must also trap the Tab key so focus cycles infinitely between the first and last focusable elements inside the modal content.
export const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children }) => {
const modalRef = useRef<HTMLDivElement>(null);
const previousActiveElement = useRef<HTMLElement | null>(null);
// Handle Escape key and focus return
useEffect(() => {
if (!isOpen) return;
// 1. Save current focus
previousActiveElement.current = document.activeElement as HTMLElement;
// 2. Focus the modal container or first focusable element
const modalElement = modalRef.current;
if (modalElement) {
const focusableElements = modalElement.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (focusableElements.length > 0) {
focusableElements[0].focus();
} else {
modalElement.focus();
}
}
// 3. Handle Escape key
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
// Focus Trap logic
if (event.key === 'Tab' && modalElement) {
const focusableElements = Array.from(
modalElement.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
আনন্দের
);
if (focusableElements.length === 0) return;
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (event.shiftKey) {
// Shift + Tab
if (document.activeElement === firstElement) {
lastElement.focus();
event.preventDefault();
}
} else {
// Tab
if (document.activeElement === lastElement) {
firstElement.focus();
event.preventDefault();
}
}
}
};
document.addEventListener('keydown', handleKeyDown);
// Cleanup: restore focus when modal unmounts or closes
return () => {
document.removeEventListener('keydown', handleKeyDown);
if (previousActiveElement.current) {
previousActiveElement.current.focus();
}
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
// JSX structure coming up next...
);
};
Pro-Tip: Querying focusable elements dynamically on every
Tabpress ensures that even if elements inside your modal are conditionally rendered or disabled, your focus trap remains resilient.
Step 3: Scroll Locking and Background Inertness
When a modal is open, users should not be able to scroll the underlying background page. Furthermore, screen readers should completely ignore background content.
Historically, developers toggled overflow: hidden on the body element and added aria-hidden="true" to root application wrappers. Today, we can leverage the native HTML inert attribute alongside standard overflow styling.
useEffect(() => {
if (!isOpen) return;
// Prevent background scrolling
const originalOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
// Make background inert for assistive tech
const rootElement = document.getElementById('root');
if (rootElement) {
rootElement.setAttribute('inert', '');
}
return () => {
document.body.style.overflow = originalOverflow;
if (rootElement) {
rootElement.removeAttribute('inert');
}
};
}, [isOpen]);
The inert attribute is a modern web standard supported across all major browsers. When an element is marked as inert, the browser ignores click events, removes it from the accessibility tree, and excludes it from tab navigation.
Step 4: Putting It Together with WAI-ARIA Markup
Now let’s assemble the complete JSX markup, ensuring we hook up role="dialog", aria-modal="true", and labeling IDs correctly.
export const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children }) => {
const modalRef = useRef<HTMLDivElement>(null);
const titleId = 'modal-title';
// ... (Include useEffect hooks for focus, escape key, and inert management from previous steps)
if (!isOpen) return null;
return (
<div className="modal-backdrop" onClick={onClose}>
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
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-btn"
>
×
</button>
</header>
<div className="modal-body">
{children}
</div>
</div>
</div>
);
};
Step 5: Styling the Modal
To ensure our modal feels solid and visually centers properly, here is a clean, modern CSS setup:
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
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;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
width: 100%;
max-width: 500px;
max-height: 90vh;
display: flex;
flex-direction: column;
outline: none;
}
.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-btn {
background: transparent;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #6b7280;
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
.modal-close-btn:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
.modal-body {
padding: 1.5rem;
overflow-y: auto;
}
Testing Your Modal
Before shipping your new modal component to production, verify it against this checklist:
- Keyboard-Only Test: Unplug your mouse. Press your trigger button, verify focus moves inside the modal, press
Tabrepeatedly to ensure focus stays trapped, and pressEscapeto close it. - Screen Reader Test: Turn on VoiceOver (macOS) or NVDA (Windows). Open the modal. Confirm that background content is ignored and screen readers announce the dialog title immediately.
- Scroll Test: Open the modal on a long page. Ensure the background body does not scroll while the modal is active.
By building modals with accessibility baked in from the ground up, you deliver a resilient, inclusive experience for all users while keeping your React bundle lean and dependency-free.