Trapped and Accessible: Building a Production-Ready Modal Dialog in React
A step-by-step engineering guide on implementing a WAI-ARIA compliant modal from scratch in React and TypeScript, focusing on focus trapping, scroll locking, and escape-key handling.
Trapped and Accessible: Building a Production-Ready Modal Dialog in React
Modals are ubiquitous in modern web applications. Whether it’s a confirmation prompt, a settings panel, or an onboarding wizard, we build them every day. Yet, if you audit most custom modals in production using a screen reader or a keyboard alone, you’ll likely find a litany of accessibility violations.
Common pitfalls include:
- Focus escaping the modal into the background content.
- The screen reader completely ignoring the background page (or failing to ignore it).
- The body background continuing to scroll when the modal is open.
- The Escape key doing nothing.
- Focus not returning to the triggering element when the modal closes.
In this guide, we are going to build a robust, production-ready, WAI-ARIA compliant Modal and Dialog component in React and TypeScript from scratch, without relying on heavy UI libraries.
The WAI-ARIA Anatomy of a Dialog
Before writing code, let’s review the specifications outlined in the WAI-ARIA Authoring Practices Guide (APG) for Modal Dialogs:
- Semantic Role: The dialog container must have
role="dialog"andaria-modal="true". - Labeling: The dialog must be labeled by its title via
aria-labelledbyoraria-label. - Focus Management:
- When the dialog opens, focus must move to an element inside the dialog.
- While open, focus must be trapped inside the dialog (Tab and Shift+Tab cycle through focusable elements only).
- When closed, focus must return to the element that triggered the modal.
- Keyboard Interactions: Pressing the
Escapekey must close the dialog. - Background Inertness: Content outside the modal must be hidden from assistive technologies (
aria-hidden="true") and rendered inert.
Step 1: Setting up the TypeScript Interface
Let’s define the props for our modal. We need to handle open states, close callbacks, labeling, and children.
import React, { ReactNode } from 'react';
export interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: ReactNode;
descriptionId?: string;
}
Step 2: Implementing Scroll Locking and Background Inertness
When a modal opens, two things must happen to the DOM outside the modal:
- The background body must stop scrolling.
- Screen readers should not access background content.
While modern browsers support the HTML inert attribute (which natively handles both pointer events and accessibility tree exclusion), we should handle body scroll locking explicitly to prevent layout shifts.
import { useEffect } from 'react';
export function useBodyScrollLock(isOpen: boolean) {
useEffect(() => {
if (!isOpen) return;
// Save original styles
const originalOverflow = document.body.style.overflow;
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
// Lock scroll and prevent layout shift caused by scrollbar disappearance
document.body.style.overflow = 'hidden';
if (scrollbarWidth > 0) {
document.body.style.paddingRight = `${scrollbarWidth}px`;
}
return () => {
document.body.style.overflow = originalOverflow;
document.body.style.paddingRight = '0px';
};
}, [isOpen]);
}
Step 3: The Focus Trap Hook
A focus trap ensures that when a user presses Tab or Shift+Tab, focus never leaves the modal container. If the user hits Tab on the last focusable element, focus wraps around to the first focusable element.
import { useEffect, useRef } from 'react';
const FOCUSABLE_SELECTOR = [
'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: boolean, onClose: () => void) {
const modalRef = useRef<HTMLDivElement>(null);
const previousActiveElement = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!isOpen) return;
// 1. Store currently focused element to restore later
previousActiveElement.current = document.activeElement as HTMLElement;
const modalElement = modalRef.current;
if (!modalElement) return;
// 2. Find all focusable elements inside the modal
const focusableElements = modalElement.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
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 & escape key
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.stopPropagation();
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 when modal unmounts/closes
previousActiveElement.current?.focus();
};
}, [isOpen, onClose]);
return modalRef;
}
Step 4: Bringing it Together into the Modal Component
Now we can combine our hooks and proper ARIA attributes into a robust React component.
import React, { useId } from 'react';
import { createPortal } from 'react-dom';
import { ModalProps } from './types';
import { useBodyScrollLock } from './useBodyScrollLock';
import { useFocusTrap } from './useFocusTrap';
export const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
children,
descriptionId,
}) => {
const titleId = useId();
const modalRef = useFocusTrap(isOpen, onClose);
useBodyScrollLock(isOpen);
if (!isOpen) return null;
return createPortal(
<div className="modal-backdrop" onClick={onClose}>
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={descriptionId}
tabIndex={-1}
className="modal-container"
onClick={(e) => e.stopPropagation()} // Prevent click bubbling from closing modal
>
<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>,
document.body
);
};
Step 5: Essential CSS for Presentation
To ensure the modal looks and behaves correctly as an overlay, apply these structural styles:
.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: #ffffff;
padding: 2rem;
border-radius: 8px;
width: 100%;
max-width: 500px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
outline: none; /* Removes default focus ring since we handle custom focus */
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.modal-title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
}
.modal-close-btn {
background: transparent;
border: none;
font-size: 1.5rem;
cursor: pointer;
padding: 0.25rem 0.5rem;
}
Testing Your Implementation
When evaluating your newly crafted modal, run through this quick checklist:
- Screen Reader Test: Open the modal using VoiceOver (macOS) or NVDA (Windows). Does it announce the title immediately? Does it read elements inside the modal before reading background content?
- Keyboard Trapping Test: Tab repeatedly inside the modal. Does focus ever leak into the background page? Does pressing Shift+Tab cycle backward correctly?
- Escape Key Test: Press
Escapewhile focused anywhere in the modal. Does it close smoothly? - Focus Restoration Test: When the modal closes, is the user’s keyboard focus returned precisely to the button that opened it?
By following this architecture, you ensure that every user—regardless of physical ability or device preference—can interact with your dialog seamlessly.