Trapped in the DOM: Building a Production-Ready Accessible Modal in React
Learn how to build a fully accessible modal dialog in React and TypeScript from scratch, featuring focus trapping, ARIA compliance, escape key handling, and background scroll locking without heavy third-party libraries.
Trapped in the DOM: Building a Production-Ready Accessible Modal in React
Modal dialogs are ubiquitous in modern web applications. Whether it’s a confirmation prompt, a complex form, or an image lightbox, modals demand the user’s immediate attention. Yet, despite their commonality, modals are among the most frequently mishandled UI components when it comes to web accessibility (a11y).
If you’ve ever found yourself tabbing through a hidden modal, accidentally scrolling the background page while reading a dialog, or failing to close a window with the Escape key, you’ve experienced broken modal accessibility.
In this walkthrough, we will build a robust, production-ready modal component in React and TypeScript without relying on heavy component libraries like Radix, Headless UI, or Material-UI. We will cover:
- The semantic HTML and ARIA requirements.
- Rendering outside the normal DOM tree using React Portals.
- Locking background body scroll correctly.
- Trapping keyboard focus within the modal.
- Restoring focus to the trigger element upon closing.
The Accessibility Blueprint
Before writing a single line of React code, we need to understand what makes a modal accessible according to the WAI-ARIA Authoring Practices Guide (APG):
- Semantics: The container must use
role="dialog"(orrole="alertdialog"if critical confirmation is needed). - Naming: It must have an accessible name via
aria-labelledbypointing to the title header, and optionallyaria-describedbyfor body text. - Focus Management: When opened, focus must move inside the modal. Tabbing must be trapped inside the modal bounds. When closed, focus must return to the element that triggered the modal.
- Keyboard Support: Pressing
Escapemust close the dialog. - Screen Readers: Everything outside the modal must be hidden from assistive technologies (
aria-hidden="true").
Step 1: The Base React & TypeScript Component Structure
Let’s start by defining our TypeScript interfaces and setting up the basic component shell. We’ll need props for visibility state, close handlers, labeling IDs, and children.
import React, { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
export const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children }) => {
if (!isOpen) return null;
return (
<div className="modal-overlay">
<div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
className="modal-content"
>
<h2 id="modal-title">{title}</h2>
<button onClick={onClose} aria-label="Close modal">
×
</button>
<div>{children}</div>
</div>
</div>
);
};
Why React Portals?
If we render our modal deep inside the DOM tree where the trigger button lives, CSS layout properties like overflow: hidden, z-index stacking contexts, or transform matrices can easily clip or misposition our modal.
Using ReactDOM.createPortal renders our modal directly into a designated DOM node (typically document.body), ensuring it sits on top of all other page content visually and structurally.
Step 2: Locking Background Body Scroll
When a modal opens, users should not be able to scroll the background content. A naive approach might just set document.body.style.overflow = 'hidden', but this can cause layout shifts due to disappearing scrollbars, and it doesn’t account for mobile touch scrolling or nested containers.
A robust approach stores the previous overflow style and restores it upon unmounting:
useEffect(() => {
if (!isOpen) return;
const originalStyle = window.getComputedStyle(document.body).overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = originalStyle;
};
}, [isOpen]);
Step 3: Handling Escape Key Dismissal
Listening for keyboard events globally requires a keydown listener attached to the window or document. We must ensure we clean up this event listener to prevent memory leaks.
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen, onClose]);
Step 4: Focus Restoration and Trapping
This is the most critical and complex part of building an accessible modal. Without focus management, screen reader users and keyboard-only users will find themselves stranded.
Focus Restoration
When the modal opens, we capture the currently focused element (document.activeElement). When the modal closes, we programmatically return focus to that exact element.
const previousActiveElementRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (isOpen) {
previousActiveElementRef.current = document.activeElement as HTMLElement;
} else if (previousActiveElementRef.current) {
previousActiveElementRef.current.focus();
}
}, [isOpen]);
Focus Trapping
Once focus is inside the modal, pressing Tab should cycle exclusively through the focusable elements within the modal. Pressing Shift + Tab should cycle backward. If focus reaches the last element and Tab is pressed, it must wrap around to the first element.
Let’s write a utility hook or effect to handle this:
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isOpen) return;
const modalElement = modalRef.current;
if (!modalElement) return;
// Find all focusable elements inside the modal
const focusableSelectors = [
'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(',');
const focusableElements = Array.from(
modalElement.querySelectorAll<HTMLElement>(focusableSelectors)
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
// Automatically focus the first element on open
firstElement?.focus();
const handleTabKey = (event: KeyboardEvent) => {
if (event.key !== 'Tab') return;
if (focusableElements.length === 0) {
event.preventDefault();
return;
}
if (event.shiftKey) {
// Shift + Tab
if (document.activeElement === firstElement) {
lastElement?.focus();
event.preventDefault();
}
} else {
// Tab
if (document.activeElement === lastElement) {
firstElement?.focus();
event.preventDefault();
}
}
};
window.addEventListener('keydown', handleTabKey);
return () => {
window.removeEventListener('keydown', handleTabKey);
};
}, [isOpen]);
Putting It All Together: The Complete Component
Here is our fully assembled, production-ready Modal component combining portals, focus trapping, scroll locking, and ARIA attributes:
import React, { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
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);
// 1. Focus capture & restoration
useEffect(() => {
if (isOpen) {
previousActiveElementRef.current = document.activeElement as HTMLElement;
} else if (previousActiveElementRef.current) {
previousActiveElementRef.current.focus();
}
}, [isOpen]);
// 2. Scroll lock & Keyboard event handlers
useEffect(() => {
if (!isOpen) return;
const originalOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const modalElement = modalRef.current;
const focusableSelectors = [
'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(',');
const focusableElements = modalElement
? Array.from(modalElement.querySelectorAll<HTMLElement>(focusableSelectors))
: [];
focusableElements[0]?.focus();
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
return;
}
if (event.key === 'Tab') {
if (focusableElements.length === 0) {
event.preventDefault();
return;
}
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (event.shiftKey) {
if (document.activeElement === firstElement) {
lastElement?.focus();
event.preventDefault();
}
} else {
if (document.activeElement === lastElement) {
firstElement?.focus();
event.preventDefault();
}
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
document.body.style.overflow = originalOverflow;
window.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return createPortal(
<div className="modal-backdrop" onClick={onClose}>
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
className="modal-container"
onClick={(e) => e.stopPropagation()} // Prevent closing when clicking inside modal content
>
<header className="modal-header">
<h2 id="modal-title">{title}</h2>
<button className="modal-close-btn" onClick={onClose} aria-label="Close modal">
✕
</button>
</header>
<div className="modal-body">{children}</div>
</div>
</div>,
document.body
);
};
Styling for Usability
An accessible modal isn’t just about JavaScript—visual styling plays a crucial role for users with low vision or cognitive impairments. Here is a baseline CSS snippet to ensure your backdrop and container render cleanly:
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-container {
background: white;
padding: 2rem;
border-radius: 8px;
max-width: 500px;
width: 90%;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
outline: none;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.modal-close-btn {
background: none;
border: none;
font-size: 1.25rem;
cursor: pointer;
}
Conclusion
Building custom UI components gives you absolute control over your bundle size and styling pipeline, but it places the burden of accessibility squarely on your shoulders. By implementing React portals, proper ARIA attributes, robust focus trapping, focus restoration, and clean event cleanup, you ensure that every user—regardless of whether they use a mouse, a keyboard, or a screen reader—can interact with your application seamlessly.