Trapped and Focused: Building an Accessible Modal Dialog in React from Scratch
{"title": "Trapped and Focused: Building an Accessible Modal Dialog in React from Scratch", "summary": "Learn how to build a fully accessible modal dialog in React and TypeScript with focus trapping, the inert attribute, screen reader announcements, and clean escape key handling.
{“title”: “Trapped and Focused: Building an Accessible Modal Dialog in React from Scratch”, “summary”: “Learn how to build a fully accessible modal dialog in React and TypeScript with focus trapping, the inert attribute, screen reader announcements, and clean escape key handling.”, “tags”: [“Accessibility”, “React”, “TypeScript”, “UI Components”], “body”: “## Introduction
Modal dialogs are one of the most common UI patterns on the web, yet they are notoriously difficult to get right from an accessibility (a11y) perspective. A truly accessible modal must do more than just look pretty centered on the screen; it must satisfy strict behavioral contracts for keyboard navigation, screen readers, and focus management.
When a modal opens, several things need to happen behind the scenes:
- Focus must move into the modal immediately.
- Focus must be trapped inside the modal so keyboard users cannot tab out to the background content.
- Background content must be hidden from assistive technologies and made inert.
- The Escape key must dismiss the dialog.
- Focus must return to the element that triggered the modal upon closing.
In this post, we will build a production-ready, highly accessible modal dialog component in React and TypeScript from scratch—no heavy third-party UI libraries required.
The Anatomy of an Accessible Modal
Before writing code, let’s understand the WAI-ARIA guidelines for dialogs (dialog). A proper modal requires specific ARIA attributes:
role=\"dialog\"orrole=\"alertdialog\"aria-modal=\"true\"to inform assistive tech that the rest of the page is blocked.aria-labelledbypointing to the dialog’s title ID.aria-describedbypointing to the dialog’s description or body text ID.
Step 1: Setting up the TypeScript Interface
Let’s define the props for our Modal component. We need controls for open state, dismissal handlers, labeling, and children.
import React, { useEffect, useRef, useState, ReactNode } from 'react';
import ReactDOM from 'react-dom';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: ReactNode;
descriptionId?: string;
}
Step 2: Implementing the Focus Trap
A focus trap prevents the user’s Tab and Shift + Tab keys from leaving the modal container. If a user tabs past the last focusable element in the modal, focus should cycle back to the first focusable element.
We can query all focusable elements inside our modal ref using a standard CSS selector:
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^=\"-\"])',
].join(',');
Inside our component, we capture the element that had focus before the modal opened so we can restore it later:
export const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
children,
}) => {
const modalRef = useRef<HTMLDivElement>(null);
const previousActiveElement = useRef<HTMLElement | null>(null);
useEffect(() => {
if (isOpen) {
// Save current focus
previousActiveElement.current = document.activeElement as HTMLElement;
// Focus the modal or its first focusable element
const focusableElements = modalRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS);
if (focusableElements && focusableElements.length > 0) {
focusableElements[0].focus();
}
} else {
// Restore focus when closing
if (previousActiveElement.current) {
previousActiveElement.current.focus();
}
}
}, [isOpen]);
Next, we handle the keydown event to trap the tab sequence:
const handleKeyDown = (event: React.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) {
if (document.activeElement === firstElement) {
lastElement.focus();
event.preventDefault();
}
} else {
if (document.activeElement === lastElement) {
firstElement.focus();
event.preventDefault();
}
}
}
};
Step 3: Utilizing the inert Attribute
Historically, hiding background content from screen readers required applying aria-hidden=\"true\" to every sibling element of the modal root. Today, modern browsers support the HTML inert attribute.
When an element is marked as inert:
- It and all its descendants are excluded from the accessibility tree.
- It cannot be clicked, touched, or focused.
- Text inside it cannot be selected.
We can apply this to our application root easily:
useEffect(() => {
const rootElement = document.getElementById('root');
if (!rootElement) return;
if (isOpen) {
rootElement.setAttribute('inert', 'true');
} else {
rootElement.removeAttribute('inert');
}
return () => {
rootElement.removeAttribute('inert');
};
}, [isOpen]);
Note: For older browsers not supporting
inert, you may want to fall back to a polyfill or manually togglearia-hiddenon application wrapper nodes.
Step 4: Assembling the Render Tree with Portals
To prevent CSS stacking context issues (z-index, overflow: hidden), modals should be rendered outside the standard DOM hierarchy using React Portals.
if (!isOpen) return null;
return ReactDOM.createPortal(
<div className=\"modal-backdrop\" onClick={onClose}>
<div
ref={modalRef}
role=\"dialog\"
aria-modal=\"true\"
aria-labelledby=\"modal-title\"
onKeyDown={handleKeyDown}
onClick={(e) => e.stopPropagation()}
className=\"modal-content\"
tabIndex={-1}
>
<div className=\"modal-header\">
<h2 id=\"modal-title\">{title}</h2>
<button
onClick={onClose}
aria-label=\"Close modal\"
className=\"modal-close-btn\"
>
×
</button>
</div>
<div className=\"modal-body\">
{children}
</div>
</div>
</div>,
document.body
);
};
Step 5: Styling for Clarity
A basic CSS setup ensures visual hierarchy, dark backdrops, and clear focus indicators:
.modal-backdrop {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: white;
padding: 2rem;
border-radius: 8px;
max-width: 500px;
width: 100%;
box-shadow: 0 10px 25px rgba(0,0,0,0.2);
outline: none; /* We manage custom focus or rely on internal elements */
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.modal-close-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
}
/* Ensure high contrast focus outlines inside the modal */
.modal-content button:focus-visible,
.modal-content input:focus-visible {
outline: 3px solid #2563eb;
outline-offset: 2px;
}
Conclusion
Building accessible components requires thinking beyond visual design and considering how keyboard operators and screen reader users experience your application. By implementing:
- Focus restoration on open/close,
- Explicit tab trapping via vanilla JavaScript query selectors,
- The
inertattribute to shield background DOM nodes, and - Semantic ARIA roles & labels,
You ensure that your modal is robust, inclusive, and compliant with modern web standards without sacrificing developer velocity or adding unnecessary heavy dependencies.”}