Building an Accessible Modal in React: Focus Traps, Inert, and Escape Key Handling
Learn how to build a fully accessible modal dialog in React using modern primitives like the inert attribute, robust focus trapping, and seamless keyboard navigation.
Building an Accessible Modal in React: Focus Traps, Inert, and Escape Key Handling
Modal dialogs are among the most common UI patterns in modern web applications. They demand user attention, interrupt workflows, and serve everything from simple confirmation dialogs to complex multi-step forms.
Yet, despite their ubiquity, modals are frequently inaccessible. If built poorly, they trap screen reader users behind invisible walls, allow keyboard focus to bleed into background content, or cause layout shifts when locking the document body scroll.
In this guide, we will build a production-ready, highly accessible modal component in React and TypeScript. We will focus on the triad of modal accessibility:
- Focus Trapping: Keeping keyboard navigation strictly inside the active dialog.
- Inert Backgrounds: Hiding background content from assistive technologies and pointer events using the native
inertattribute. - Keyboard & Scroll Management: Handling the
Escapekey and preventing background layout shifts cleanly.
The Anatomy of an Accessible Modal
Before writing code, let’s establish what assistive technologies expect from a proper modal dialog according to the WAI-ARIA Authoring Practices Guide (APG):
- Semantics: The root container must use
role="dialog"andaria-modal="true". - Labeling: It must have an accessible name, usually provided via
aria-labelledbypointing to the modal title element. - Initial Focus: When the modal opens, focus must immediately move to an interactive element inside the modal (ideally the close button or the first form input).
- Return Focus: When the modal closes, focus must return to the exact element that triggered its opening.
- Inertness: Everything outside the modal must be rendered inaccessible to both screen readers and keyboard users.
Step 1: Setting up the React Component and Portal
Modals should live at the root of your DOM tree (usually directly inside document.body) to avoid clipping issues caused by CSS overflow, z-index, or transform properties on parent containers. We will use ReactDOM.createPortal.
import React, { useEffect, useRef } from 'react';
import ReactDOM from 'react-dom';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
export const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children }) => {
if (!isOpen) return null;
return ReactDOM.createPortal(
<div className="modal-overlay">
<div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
className="modal-content"
>
<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>
</div>
</div>,
document.body
);
};
Step 2: Managing Focus and the Escape Key
When the modal opens, we need to save the element that currently has focus so we can restore it later. We also need to listen for the Escape key to close the modal.
Creating the Focus Trap
A focus trap prevents the user’s Tab key from leaving the modal. When the user tabs past the last focusable element, focus wraps back to the first element. Conversely, Shift + Tab on the first element wraps to the last.
Let’s write a custom hook or utility to handle query selectors for focusable elements:
const FOCUSABLE_ELEMENTS = [
'a[href]',
'area[href]',
'input:not([disabled]):not([type="hidden"])',
'select:not([disabled])',
'textarea:not([disabled])',
'button:not([disabled])',
'[tabindex="0"]',
].join(', ');
Now, let’s integrate focus management and event listeners into our modal component:
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 current focus
previousActiveElement.current = document.activeElement as HTMLElement;
// 2. Set initial focus inside modal
const modalNode = modalRef.current;
if (modalNode) {
const focusableElements = modalNode.querySelectorAll<HTMLElement>(FOCUSABLE_ELEMENTS);
if (focusableElements.length > 0) {
focusableElements[0].focus();
}
}
// 3. Handle Escape key and Tab trapping
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
return;
}
if (event.key === 'Tab' && modalNode) {
const focusableElements = modalNode.querySelectorAll<HTMLElement>(FOCUSABLE_ELEMENTS);
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();
}
}
}
};
document.addEventListener('keydown', handleKeyDown);
// Cleanup: restore focus
return () => {
document.removeEventListener('keydown', handleKeyDown);
if (previousActiveElement.current) {
previousActiveElement.current.focus();
}
};
}, [isOpen, onClose]);
if (!isOpen) return;
return (
// ... Portal markup goes here, attaching modalRef to the dialog
);
};
Step 3: Making the Background Inert
Historically, developers used aria-hidden="true" on all sibling root containers of the modal to hide background content from screen readers, combined with complex click-handlers to block mouse events.
Today, modern browsers natively support the inert attribute. When an element is marked inert:
- It is removed from the accessibility tree.
- All descendant elements become un-clickable, un-focusable, and ignored by assistive technologies.
Implementing Inert in React
We can find our application root container (commonly #root or main) and toggle the inert property dynamically.
useEffect(() => {
if (!isOpen) return;
const rootElement = document.getElementById('root');
if (rootElement) {
rootElement.setAttribute('inert', '');
}
return () => {
if (rootElement) {
rootElement.removeAttribute('inert');
}
};
}, [isOpen]);
Note: For older browsers that do not support
inertnatively, you should include the official W3Cw3c-inertpolyfill to ensure backward compatibility.
Step 4: Preventing Background Scroll Without Layout Shift
When a modal opens, background scrolling must be locked. A naive approach sets document.body.style.overflow = 'hidden'. However, this causes a jarring horizontal layout shift (jank) on desktop operating systems because the vertical scrollbar disappears, causing the page width to expand instantly.
To prevent this, we calculate the width of the missing scrollbar and compensate by applying matching right padding to the body.
useEffect(() => {
if (!isOpen) return;
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
const originalOverflow = document.body.style.overflow;
const originalPaddingRight = document.body.style.paddingRight;
document.body.style.overflow = 'hidden';
if (scrollbarWidth > 0) {
document.body.style.paddingRight = `${scrollbarWidth}px`;
}
return () => {
document.body.style.overflow = originalOverflow;
document.body.style.paddingRight = originalPaddingRight;
};
}, [isOpen]);
Putting It All Together
Here is the complete, cohesive implementation of our accessible modal component:
import React, { useEffect, useRef } from 'react';
import ReactDOM from 'react-dom';
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])',
'[tabindex="0"]',
].join(', ');
export const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children }) => {
const modalRef = useRef<HTMLDivElement>(null);
const previousFocus = useRef<HTMLElement | null>(null);
// 1. Body scroll lock & layout shift prevention
useEffect(() => {
if (!isOpen) return;
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
const originalOverflow = document.body.style.overflow;
const originalPaddingRight = document.body.style.paddingRight;
document.body.style.overflow = 'hidden';
if (scrollbarWidth > 0) {
document.body.style.paddingRight = `${scrollbarWidth}px`;
}
return () => {
document.body.style.overflow = originalOverflow;
document.body.style.paddingRight = originalPaddingRight;
};
}, [isOpen]);
// 2. Background inert management
useEffect(() => {
if (!isOpen) return;
const rootElement = document.getElementById('root');
if (rootElement) rootElement.setAttribute('inert', '');
return () => {
if (rootElement) rootElement.removeAttribute('inert');
};
}, [isOpen]);
// 3. Focus trap and keyboard handling
useEffect(() => {
if (!isOpen) return;
previousFocus.current = document.activeElement as HTMLElement;
const modalNode = modalRef.current;
if (modalNode) {
const focusables = modalNode.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS);
if (focusables.length > 0) focusables[0].focus();
}
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
return;
}
if (e.key === 'Tab' && modalNode) {
const focusables = modalNode.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS);
if (focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) {
last.focus();
e.preventDefault();
} else if (!e.shiftKey && document.activeElement === last) {
first.focus();
e.preventDefault();
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
if (previousFocus.current) {
previousFocus.current.focus();
}
};
}, [isOpen, onClose]);
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"
className="modal-window"
onClick={(e) => e.stopPropagation()}
>
<header className="modal-header">
<h2 id="modal-title">{title}</h2>
<button className="close-btn" onClick={onClose} aria-label="Close modal">
×
</button>
</header>
<div className="modal-body">{children}</div>
</div>
</div>,
document.body
);
};
Conclusion
Building a robust, accessible modal in React requires careful coordination of semantics, DOM placement, keyboard listeners, and browser APIs. By leveraging the native inert attribute, implementing precise focus trapping routines, and gracefully handling scrollbar padding adjustments, you ensure that your modal works flawlessly for every user—regardless of whether they use a mouse, a keyboard, or a screen reader.