Trap, Esc, and Focus: Building an Accessible Modal Dialog in React from Scratch
Learn how to build a production-ready, fully accessible modal dialog in React and TypeScript featuring robust focus trapping, Escape-key dismissal, focus restoration, and the modern inert attribute.
Trap, Esc, and Focus: Building an Accessible Modal Dialog in React from Scratch
Modals are one of the most common UI patterns in modern web development, yet they are frequently broken for keyboard and screen reader users. When a modal opens, focus often leaks into the background content, screen readers continue to announce hidden elements, and pressing the Escape key does nothing.
To build a truly accessible modal dialog from scratch, we need to solve several technical challenges:
- Focus Management: Moving focus inside the modal upon opening and preventing it from escaping.
- Focus Restoration: Returning focus to the triggering element when the modal closes.
- Keyboard Interactivity: Listening for the
Escapekey to dismiss the dialog. - Background Isolation: Utilizing the modern HTML
inertattribute to hide background content from assistive technologies. - Semantics: Using native semantic HTML elements (
<dialog>) and ARIA attributes for screen reader compatibility.
In this post, we will build a robust, accessible modal component in React and TypeScript without relying on heavy third-party UI libraries.
The Anatomy of an Accessible Dialog
Before writing code, let’s review the requirements mandated by the WAI-ARIA Authoring Practices Guide (APG) for dialog modals:
- The dialog container must have
role="dialog"andaria-modal="true". - It must have an accessible name, provided via
aria-labelledbypointing to the modal’s title. - When open, background content must be rendered inert or hidden from assistive technology.
- Keyboard focus must be trapped inside the modal. Tabbing forward from the last focusable element must cycle back to the first focusable element.
- Pressing
Escapemust close the modal. - Focus must return to the element that triggered the modal upon closing.
Let’s implement these requirements step by step.
Step 1: Component Shell and TypeScript Types
Let’s start by defining our component props and basic layout using TypeScript. We’ll use a native HTML <dialog> element as our foundation, which gives us built-in semantics.
import React, { useEffect, useRef } from 'react';
export interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
export const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
children,
}) => {
const dialogRef = useRef<HTMLDialogElement>(null);
if (!isOpen) return null;
return (
<div className="modal-backdrop">
<dialog
ref={dialogRef}
aria-modal="true"
aria-labelledby="modal-title"
className="modal-dialog"
open
>
<div className="modal-header">
<h2 id="modal-title">{title}</h2>
<button onClick={onClose} aria-label="Close modal">
×
</button>
</div>
<div className="modal-body">{children}</div>
</dialog>
</div>
);
};
Step 2: Preserving and Restoring Focus
When a modal opens, the user’s focus should immediately transition inside the dialog. When the modal closes, focus must return to the exact element that triggered it. Otherwise, keyboard and screen reader users will lose their place on the page.
We can capture the active element using document.activeElement right before opening, and restore it when unmounting or closing.
export const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
children,
}) => {
const dialogRef = useRef<HTMLDialogElement>(null);
const previousActiveElement = useRef<HTMLElement | null>(null);
useEffect(() => {
if (isOpen) {
// 1. Save current focus
previousActiveElement.current = document.activeElement as HTMLElement;
// 2. Focus the dialog or its first focusable element
const focusableElements = getFocusableElements(dialogRef.current);
if (focusableElements.length > 0) {
focusableElements[0].focus();
}
} else {
// 3. Restore focus on close
previousActiveElement.current?.focus();
}
}, [isOpen]);
if (!isOpen) return null;
// ... render logic
};
Helper: Finding Focusable Elements
To manage focus inside our trap, we need a helper utility that queries all focusable elements within a given container:
const FOCUSABLE_SELECTORS = [
'a[href]',
'area[href]',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'button:not([disabled])',
'[tabindex="0"]',
'[tabindex]:not([tabindex="-1"])',
].join(', ');
export function getFocusableElements(container: HTMLElement | null): HTMLElement[] {
if (!container) return [];
return Array.from(
container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS)
).filter(
(el) => !el.hasAttribute('disabled') && !el.getAttribute('aria-hidden')
);
}
Step 3: Implementing the Focus Trap and Escape Key Handler
If a user presses Tab while focused on the last interactive element inside the modal, focus must loop back to the first element. Conversely, Shift + Tab on the first element should loop to the last element.
We also need to listen for the Escape key globally while the modal is open.
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
onClose();
return;
}
if (event.key === 'Tab') {
const focusable = getFocusableElements(dialogRef.current);
if (focusable.length === 0) return;
const firstElement = focusable[0];
const lastElement = focusable[focusable.length - 1];
if (event.shiftKey) {
// Shift + Tab: if focused on first, wrap to last
if (document.activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
}
} else {
// Tab: if focused on last, wrap to first
if (document.activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen, onClose]);
Step 4: Securing the Background with the inert Attribute
Historically, hiding background content from screen readers and pointer events required complex combinations of aria-hidden="true" applied to all sibling nodes of the root app container.
Today, modern browsers support the inert attribute. When an element is marked as inert:
- The browser removes it and all its descendants from the accessibility tree.
- It ignores click and touch events.
- It removes all nested elements from tab navigation.
We can easily toggle inert on our root application element (#root or main) whenever our modal opens:
useEffect(() => {
if (!isOpen) return;
// Assuming your React app is mounted in an element with id="root"
const rootElement = document.getElementById('root');
if (rootElement) {
rootElement.setAttribute('inert', '');
}
return () => {
if (rootElement) {
rootElement.removeAttribute('inert');
}
};
}, [isOpen]);
Note for older browsers: While
inertis supported in all modern evergreen browsers (Chrome 105+, Safari 15.4+, Firefox 113+), if you need to support legacy browsers, consider polyfillinginertusingw3c/inert.
Step 5: Putting It All Together
Here is the complete, integrated React TypeScript modal component incorporating focus trapping, keyboard navigation, focus restoration, and the inert attribute:
import React, { useEffect, useRef } from 'react';
export interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
const FOCUSABLE_SELECTORS = [
'a[href]',
'area[href]',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'button:not([disabled])',
'[tabindex="0"]',
'[tabindex]:not([tabindex="-1"])',
].join(', ');
function getFocusableElements(container: HTMLElement | null): HTMLElement[] {
if (!container) return [];
return Array.from(
container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS)
).filter(
(el) => !el.hasAttribute('disabled') && !el.getAttribute('aria-hidden')
);
}
export const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
children,
}) => {
const dialogRef = useRef<HTMLDialogElement>(null);
const previousActiveElement = useRef<HTMLElement | null>(null);
// Handle focus storage, restoration, and inert attribute
useEffect(() => {
if (isOpen) {
previousActiveElement.current = document.activeElement as HTMLElement;
const rootElement = document.getElementById('root');
if (rootElement) rootElement.setAttribute('inert', '');
const focusable = getFocusableElements(dialogRef.current);
if (focusable.length > 0) {
focusable[0].focus();
}
return () => {
if (rootElement) rootElement.removeAttribute('inert');
previousActiveElement.current?.focus();
};
}
}, [isOpen]);
// Handle keyboard events (Escape and Tab trapping)
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
onClose();
return;
}
if (event.key === 'Tab') {
const focusable = getFocusableElements(dialogRef.current);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div className="modal-backdrop" onClick={onClose}>
<div
className="modal-positioner"
onClick={(e) => e.stopPropagation()} // Prevent backdrop clicks from closing immediately if desired
>
<dialog
ref={dialogRef}
open
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
className="modal-content"
>
<div className="modal-header">
<h2 id="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>
</dialog>
</div>
</div>
);
};
Conclusion
Building an accessible modal dialog requires attention to detail beyond mere visual styling. By combining semantic markup (role="dialog", aria-modal="true"), programmatic focus management, keyboard event listeners (Escape and Tab wrapping), and the modern inert attribute, you ensure that every user—regardless of whether they use a mouse, screen reader, or keyboard—has a seamless experience.