Trapped in a Dialog: Building an Accessible Modal from Scratch in React
Learn how to build a fully accessible modal dialog in React with TypeScript, featuring focus trapping, background inertness, and escape key handling without external UI libraries.
Trapped in a Dialog: Building an Accessible Modal from Scratch in React
Modals are one of the most common UI patterns on the web. They appear everywhere: for confirmation prompts, image lightboxes, user settings, and complex forms. Yet, despite their ubiquity, they are frequently implemented incorrectly, creating massive accessibility (a11y) barriers for screen reader users, keyboard navigators, and individuals with motor impairments.
Building an accessible modal isn’t just about throwing position: fixed and z-index: 9999 on a div. A truly accessible modal requires managing four distinct pillars:
- Semantic HTML & ARIA Attributes: Telling assistive technologies what the element is and what it controls.
- Focus Management: Trapping the keyboard focus inside the modal and restoring it when the modal closes.
- Background Inertness: Removing background content from the accessibility tree so users don’t accidentally navigate outside the modal.
- Event Handling: Listening for the
Escapekey and clicks outside the modal content.
In this guide, we will build a production-ready, accessible modal component from scratch in React and TypeScript—without relying on any external UI library or heavy hook dependency.
1. The Anatomy of an Accessible Modal
Before writing code, let’s establish the required HTML structure and ARIA contract. According to the WAI-ARIA Authoring Practices Guide (APG), a dialog must:
- Have
role="dialog". - Have
aria-modal="true"to inform screen readers that the rest of the page is blocked. - Have an accessible name via
aria-labelledbypointing to the modal title, oraria-label. - Manage keyboard focus so that pressing
Tabcycles exclusively through interactive elements inside the modal.
Here is our TypeScript interface for the modal props:
import React, { ReactNode } from 'react';
export interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: ReactNode;
}
2. Implementing Focus Trapping
When a modal opens, keyboard focus must instantly move inside it. If a user presses Tab on the last interactive element, focus must wrap back to the first interactive element. Conversely, Shift + Tab on the first element must wrap to the last.
To achieve this without external libraries, we can query all focusable elements inside our modal container.
Finding Focusable Elements
A focusable element includes links, buttons, inputs, selects, textareas, and any element with a positive tabindex.
const FOCUSABLE_SELECTORS = [
'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(',');
The Focus Trap Hook/Logic
Inside our component, we use useRef to reference the modal container and intercept keydown events for the Tab key.
import React, { useEffect, useRef } from 'react';
export const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children }) => {
const modalRef = useRef<HTMLDivElement>(null);
const previousActiveElement = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!isOpen) return;
// 1. Save the currently focused element to restore it later
previousActiveElement.current = document.activeElement as HTMLElement;
const modalElement = modalRef.current;
if (!modalElement) return;
// 2. Focus the first focusable element inside the modal
const focusableElements = modalElement.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (firstElement) {
firstElement.focus();
}
// 3. Handle Tab key trapping
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
if (event.key === 'Tab') {
if (focusableElements.length === 0) {
event.preventDefault();
return;
}
if (event.shiftKey) {
// If shift + tab and focus is on first element, wrap to last
if (document.activeElement === firstElement) {
event.preventDefault();
lastElement?.focus();
}
} else {
// If tab and focus is on last element, wrap to first
if (document.activeElement === lastElement) {
event.preventDefault();
firstElement?.focus();
}
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
// 4. Restore focus when modal unmounts/closes
previousActiveElement.current?.focus();
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
/* JSX layout goes here */
);
};
Crucial UX Note: Always restore focus to the element that triggered the modal (stored in
previousActiveElement.current). Dropping user focus back to the top of the document (<body>) disorients keyboard and screen reader users.
3. Making the Background Inert with aria-hidden
When a modal is open, users should not be able to interact with or read content in the background. While the native HTML <dialog> element handles this natively via .showModal(), custom React implementations require manual intervention.
We can hide the rest of the application by querying siblings of our React root or utilizing a designated portal wrapper, applying aria-hidden="true".
useEffect(() => {
if (!isOpen) return;
// Assuming your React app is rendered inside an element with id="root"
const rootElement = document.getElementById('root');
if (rootElement) {
rootElement.setAttribute('aria-hidden', 'true');
}
return () => {
if (rootElement) {
rootElement.removeAttribute('aria-hidden');
}
};
}, [isOpen]);
This guarantees that screen readers completely ignore background DOM nodes, preventing users from tabbing or reading content outside the active modal container.
4. Assembling the Complete Component
Let’s bring everything together into a robust, styled React component. We will use React Portals (ReactDOM.createPortal) to render the modal directly into document.body, preventing clipping issues caused by CSS stacking contexts (z-index or overflow: hidden on parent containers).
import React, { useEffect, useRef } from 'react';
import ReactDOM from 'react-dom';
import './Modal.css';
export interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
const FOCUSABLE_SELECTORS = [
'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 const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children }) => {
const modalRef = useRef<HTMLDivElement>(null);
const previousActiveElement = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!isOpen) return;
previousActiveElement.current = document.activeElement as HTMLElement;
const modalElement = modalRef.current;
const rootElement = document.getElementById('root');
if (rootElement) {
rootElement.setAttribute('aria-hidden', 'true');
}
if (modalElement) {
const focusableElements = modalElement.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (firstElement) firstElement.focus();
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
if (event.key === 'Tab') {
if (focusableElements.length === 0) {
event.preventDefault();
return;
}
if (event.shiftKey && document.activeElement === firstElement) {
event.preventDefault();
lastElement?.focus();
} else if (!event.shiftKey && document.activeElement === lastElement) {
event.preventDefault();
firstElement?.focus();
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
if (rootElement) rootElement.removeAttribute('aria-hidden');
previousActiveElement.current?.focus();
};
}
}, [isOpen, onClose]);
if (!isOpen) return null;
return ReactDOM.createPortal(
<div className="modal-backdrop" onClick={onClose}>
<div
ref={modalRef}
className="modal-container"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
onClick={(e) => e.stopPropagation()} // Prevent backdrop clicks from bubbling
>
<header className="modal-header">
<h2 id="modal-title" className="modal-title">
{title}
</h2>
<button
className="modal-close-button"
onClick={onClose}
aria-label="Close modal"
>
×
</button>
</header>
<div className="modal-body">{children}</div>
</div>
</div>,
document.body
);
};
5. Styling the Modal (CSS)
To ensure our modal looks professional and centers correctly across viewport sizes, add the following CSS rules:
.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;
padding: 1rem;
}
.modal-container {
background: #ffffff;
border-radius: 8px;
width: 100%;
max-width: 500px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
display: flex;
flex-direction: column;
max-height: 90vh;
overflow: hidden;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
border-bottom: 1px solid #e2e8f0;
}
.modal-title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: #1a202c;
}
.modal-close-button {
background: transparent;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #4a5568;
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
.modal-close-button:hover,
.modal-close-button:focus-visible {
background-color: #edf2f7;
outline: 2px solid #3182ce;
}
.modal-body {
padding: 1.5rem;
overflow-y: auto;
}
Conclusion
Building an accessible modal dialog requires attention to detail, but you don’t need external UI component libraries to get it right. By combining React Portals, programmatic focus management, aria-hidden background isolation, and clean keyboard event listeners, you ensure your application is inclusive for every user.
Always test your modals using only a keyboard (Tab, Shift + Tab, and Escape) and a screen reader (like VoiceOver on macOS or NVDA on Windows) to verify that your implementation meets modern web accessibility standards.