Building an Accessible Modal in React: Focus Traps, Escape Keys, and ARIA Roles
A comprehensive guide to building production-ready, fully accessible modal dialogs in React using TypeScript, React Portals, focus trapping, and ARIA attributes.
Building an Accessible Modal in React: Focus Traps, Escape Keys, and ARIA Roles
Modals are one of the most common UI patterns in modern web applications. Yet, they are also one of the most frequently broken components when it comes to web accessibility (a11y).
When a modal pops up, a lot has to happen behind the scenes to ensure screen reader users, keyboard-only users, and motor-impaired users can navigate it seamlessly. Without proper handling, focus can leak into the background, screen readers will read content behind the overlay, and users can get trapped with no intuitive way out.
In this practical guide, we will build a production-ready, highly accessible modal component in React using TypeScript, React Portals, focus trapping, Escape key listeners, body scroll locking, and correct ARIA attributes.
The Anatomy of an Accessible Modal
Before writing code, let’s establish what makes a modal accessible:
- Semantic HTML & ARIA Attributes: The container must use
role="dialog",aria-modal="true", and reference an accessible title and description. - React Portals: The modal must be rendered outside the main DOM tree (usually into a dedicated
#modal-root) to avoid z-index stacking context issues and screen reader confusion. - Focus Management:
- Initial Focus: When the modal opens, focus moves to the first focusable element inside the modal (or the modal container itself).
- Focus Trap: Tab and Shift+Tab cycles through the modal’s focusable elements without escaping to the background.
- Return Focus: When the modal closes, focus returns to the exact element that triggered it.
- Keyboard Interactivity: Pressing the
Escapekey must close the modal. - Background Inertness: Background content must be hidden from assistive technologies (
aria-hidden="true"orinert), and body scrolling must be locked.
Step 1: Setting up the TypeScript Interface
Let’s start by defining the props for our modal component. We need a way to control its open state, handle close events, and provide a title for screen readers.
import React, { ReactNode } from 'react';
export interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: ReactNode;
className?: string;
}
Step 2: Implementing the React Portal
To ensure our modal sits cleanly on top of the DOM hierarchy regardless of where it is invoked, we render it into a portal container.
import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { ModalProps } from './types';
export const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
children,
className = '',
}) => {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
return () => setMounted(false);
}, []);
if (!isOpen || !mounted) return null;
return createPortal(
<div className="modal-overlay">
{/* Modal content goes here */}
</div>,
document.getElementById('modal-root') || document.body
);
};
Step 3: Managing Focus Trapping and Keyboard Events
Focus trapping is the core requirement of an accessible modal. When a user presses Tab, the focus must not leave the modal dialog. If they reach the last focusable element and press Tab, it loops back to the first. If they press Shift + Tab on the first element, it wraps to the last.
Let’s build a custom hook or effect that queries all focusable elements inside the modal ref.
import React, { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { ModalProps } from './types';
const FOCUSABLE_SELECTORS = [
'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(',');
export const AccessibleModal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
children,
className = '',
}) => {
const modalRef = useRef<HTMLDivElement>(null);
const previousActiveElement = useRef<HTMLElement | null>(null);
// 1. Save previous focus and restore on close
useEffect(() => {
if (isOpen) {
previousActiveElement.current = document.activeElement as HTMLElement;
// Lock body scroll
document.body.style.overflow = 'hidden';
}
return () => {
document.body.style.overflow = 'unset';
if (previousActiveElement.current) {
previousActiveElement.current.focus();
}
};
}, [isOpen]);
// 2. Handle Escape key and Focus Trap
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
return;
}
if (event.key === 'Tab' && modalRef.current) {
const focusableElements = modalRef.current.querySelectorAll<HTMLElement>(
FOCUSABLE_SELECTORS
);
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);
// Initial focus inside modal
const timer = setTimeout(() => {
if (modalRef.current) {
const firstFocusable = modalRef.current.querySelector<HTMLElement>(
FOCUSABLE_SELECTORS
);
if (firstFocusable) {
firstFocusable.focus();
} else {
modalRef.current.focus();
}
}
}, 50);
return () => {
document.removeEventListener('keydown', handleKeyDown);
clearTimeout(timer);
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return createPortal(
<div
className="modal-backdrop"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabIndex={-1}
className={`modal-container ${className}`}
>
<div className="modal-header">
<h2 id="modal-title" className="modal-title">
{title}
</h2>
<button
type="button"
onClick={onClose}
aria-label="Close modal"
className="modal-close-btn"
>
×
</button>
</div>
<div className="modal-body">
{children}
</div>
</div>
</div>,
document.body
);
};
Step 4: ARIA Roles & Screen Reader Best Practices
Let’s break down the critical accessibility attributes used on the modal container:
role="dialog": Informs assistive technologies that this element is a dialog window.aria-modal="true": Tells screen readers that the rest of the application is inert/hidden while the modal is open. (Note: Combining this with settingaria-hidden="true"on your main application root adds maximum browser support).aria-labelledby="modal-title": Associates the modal dialog with its header text so screen readers announce the title immediately upon opening.tabIndex={-1}: Allows the modal container itself to receive programmatic focus if no focusable interactive elements exist inside it.
Step 5: Styling for Production
To make our modal visually polished while respecting user motion preferences, we can add simple CSS with smooth transitions:
.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;
animation: fadeIn 0.2s ease-out;
}
.modal-container {
background: white;
border-radius: 8px;
padding: 24px;
width: 100%;
max-width: 500px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
outline: none;
animation: scaleUp 0.2s ease-out;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.modal-title {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
}
.modal-close-btn {
background: transparent;
border: none;
font-size: 1.5rem;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
}
.modal-close-btn:focus-visible {
outline: 2px solid #2563eb;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes scaleUp {
from { transform: scale(0.95); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
/* Respect user motion preferences */
@media (prefers-reduced-motion: reduce) {
.modal-backdrop, .modal-container {
animation: none;
}
}
Conclusion
Building an accessible modal in React requires attention to detail, combining keyboard event listeners, DOM focus APIs, semantic ARIA attributes, and React Portals.
By ensuring focus is trapped, keyboard shortcuts like Escape are handled, and focus is correctly returned upon closing, you provide an inclusive experience for every user visiting your web application. You can now wrap this component, add custom animations, and use it safely across your entire design system.