Trapped in the DOM: Building a Fully Accessible Modal Dialog in React
Learn how to build a zero-dependency, fully accessible modal dialog in React and TypeScript featuring focus trapping, portal rendering, and proper ARIA attributes.
Trapped in the DOM: Building a Fully Accessible Modal Dialog in React
Modals are ubiquitous in modern web applications. Whether they are used for confirming a destructive action, presenting a detailed form, or displaying important alerts, modals demand the user’s immediate attention. However, despite their commonality, modals are notoriously mishandled when it comes to web accessibility (a11y).
A poorly built modal is a labyrinth for screen reader users and keyboard navigators. Focus gets lost in the background DOM, the Escape key does nothing, and screen readers continue to read content that is visually obscured behind a backdrop.
In this technical guide, we will build a robust, zero-dependency, fully accessible modal component in React and TypeScript. We will cover portal rendering, focus trapping, keyboard navigation, and ARIA attributes without relying on heavy external libraries.
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), an accessible modal dialog must satisfy the following criteria:
- Semantic Structure: It must use the
role="dialog"andaria-modal="true"attributes. - Labelling: It must have an accessible name via
aria-labelledbyoraria-label. - DOM Portaling: It should be rendered at the root of the DOM tree to prevent clipping and stacking context issues (
z-index). - Focus Management:
- Focus must move into the modal when it opens.
- Focus must be trapped inside the modal while it is open.
- Focus must return to the element that triggered the modal when it closes.
- Keyboard Support: Pressing the
Escapekey must close the modal. - Background Inertness: Content outside the modal should be hidden from assistive technologies (using
aria-hiddenor the nativeinertattribute).
Step 1: Setting up the TypeScript Interfaces
Let’s start by defining the props for our Modal component. We need a way to control its open state, handle closures, provide a title, and accept children.
import React, { useEffect, useRef, ReactNode } from 'react';
import { createPortal } from 'react-dom';
export interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: ReactNode;
ariaDescribedBy?: string;
}
Step 2: Rendering via React Portals
By default, a component renders inside its parent container in the React tree. If the parent has overflow: hidden or a restrictive z-index, our modal might get clipped or hidden. We use ReactDOM.createPortal to render the modal directly into document body.
export const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
children,
ariaDescribedBy,
}) => {
if (!isOpen) return null;
return createPortal(
<div className="modal-backdrop" onClick={onClose}>
<div
className="modal-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
aria-describedby={ariaDescribedBy}
onClick={(e) => e.stopPropagation()}
>
<h2 id="modal-title">{title}</h2>
<div className="modal-content">{children}</div>
<button onClick={onClose} aria-label="Close modal">
Close
</button>
</div>
</div>,
document.body
);
};
Notice the e.stopPropagation() on the inner dialog container. This ensures that clicking inside the modal box doesn’t trigger the backdrop’s onClose click handler, while clicking outside the box does.
Step 3: Implementing the Focus Trap
A focus trap prevents keyboard users (using the Tab key) from cycling out of the modal and into the background application.
To build a focus trap, we need to query all focusable elements inside our modal container and intercept the Tab keydown event:
const FOCUSABLE_ELEMENTS = [
'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(',');
Now, let’s incorporate this logic into a ref within our Modal component:
export const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
children,
ariaDescribedBy,
}) => {
const modalRef = useRef<HTMLDivElement>(null);
const previousActiveElement = useRef<HTMLElement | null>(null);
// Handle focus management and trapping
useEffect(() => {
if (!isOpen) return;
// Save current active element to restore focus later
previousActiveElement.current = document.activeElement as HTMLElement;
const modalElement = modalRef.current;
if (!modalElement) return;
// Find all focusable elements
const focusableElements = modalElement.querySelectorAll<HTMLElement>(FOCUSABLE_ELEMENTS);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
// Focus the first element inside the modal
firstElement?.focus();
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
if (e.key === 'Tab') {
if (focusableElements.length === 0) {
e.preventDefault();
return;
}
if (e.shiftKey) {
// If shift + tab and focus is on first element, wrap to last
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement?.focus();
}
} else {
// If tab and focus is on last element, wrap to first
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement?.focus();
}
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
// Restore focus to the element that opened the modal
previousActiveElement.current?.focus();
};
}, [isOpen, onClose]);
if (!isOpen) return;
return createPortal(
<div className="modal-backdrop" onClick={onClose}>
<div
ref={modalRef}
className="modal-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
aria-describedby={ariaDescribedBy}
onClick={(e) => e.stopPropagation()}
>
<h2 id="modal-title">{title}</h2>
<div className="modal-content">{children}</div>
<button onClick={onClose} aria-label="Close modal">
Close
</button>
</div>
</div>,
document.body
);
};
Step 4: Styling and Polish
To make our modal visually clear and professional, we add CSS styles that enforce a dark translucent backdrop, center alignment, and smooth appearance transitions.
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-dialog {
background: #ffffff;
padding: 2rem;
border-radius: 8px;
width: 100%;
max-width: 500px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
outline: none;
display: flex;
flex-direction: column;
gap: 1rem;
}
.modal-content {
margin: 1rem 0;
}
Step 5: Consuming the Modal Component
Using our newly minted accessible modal is straightforward. Here is an example of a dashboard component triggering the modal state:
import React, { useState } from 'react';
import { Modal } from './Modal';
export const Dashboard: React.FC = () => {
const [isModalOpen, setIsModalOpen] = useState(false);
return (
<main>
<h1>User Dashboard</h1>
<button onClick={() => setIsModalOpen(true)}>
Edit Profile
</button>
<Modal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
title="Edit Profile Settings"
ariaDescribedBy="profile-description"
>
<p id="profile-description">
Make changes to your account settings below.
</p>
<form onSubmit={(e) => { e.preventDefault(); setIsModalOpen(false); }}>
<label htmlFor="username">Username:</label>
<input id="username" type="text" defaultValue="johndoe" />
<div style={{ marginTop: '1rem' }}>
<button type="submit">Save Changes</button>
</div>
</form>
</Modal>
</main>
);
};
Conclusion
Building an accessible modal dialog requires going beyond basic UI styling. By properly leveraging React portals, handling keyboard events (Escape and Tab), trapping focus, and applying exact WAI-ARIA semantics (role="dialog", aria-modal="true"), you ensure that users of all abilities can navigate your application seamlessly.
By keeping your component zero-dependency, you maintain full control over performance, styling, and behavior without bloating your bundle with external UI monoliths.