Building an Accessible Modal Dialog in React: Focus Traps & Inert Content
A practical, code-heavy guide to building production-ready modal dialogs in React with robust focus trapping, inert background content, scroll locking, and accessible keyboard interactions.
Building an Accessible Modal Dialog in React: Focus Traps & Inert Content
When building user interfaces, few components are as deceptively complex as the modal dialog. At first glance, it’s just a box centered on the screen with a backdrop. But once you look through the lens of accessibility (a11y), a proper modal requires managing keyboard focus, hiding background content from screen readers, locking the body scroll, and handling escape keys gracefully.
In this walkthrough, we will build a robust, production-ready <Modal /> component in React using TypeScript. We will tackle the four pillars of modal accessibility:
- Focus Management: Trapping keyboard navigation within the modal.
- Screen Reader Isolation: Using the native
inertattribute to render background content inaccessible. - Scroll Locking: Preventing the underlying body from scrolling.
- Focus Restoration: Returning focus to the triggering element upon closure.
The Anatomy of an Accessible Modal
Before writing code, let’s review the WAI-ARIA Authoring Practices Guide (APG) requirements for a Dialog (Modal):
- The dialog container must have
role="dialog"andaria-modal="true". - It must have an accessible name, usually via
aria-labelledbypointing to the title heading ID. - When open, focus must move to an element inside the dialog.
- Tab and Shift+Tab must be trapped inside the dialog.
- Pressing
Escapemust close the dialog. - Clicking the backdrop should optionally close the dialog.
Let’s implement these features step-by-step.
Step 1: The Component Skeleton & Portals
To prevent CSS clipping and z-index stacking context issues, our modal should render outside the normal DOM hierarchy using React Portals.
import React, { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
export const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children }) => {
const modalRef = useRef<HTMLDivElement>(null);
if (!isOpen) return null;
return createPortal(
<div className="modal-backdrop" onClick={onClose}>
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
className="modal-container"
onClick={(e) => e.stopPropagation()}
>
<h2 id="modal-title">{title}</h2>
<div className="modal-content">{children}</div>
<button onClick={onClose} aria-label="Close modal">
Close
</button>
</div>
</div>,
document.body
);
};
Step 2: Managing Focus Restoration and Trapping
When the modal opens, we need to save the element that currently has focus (the trigger) so we can restore it when the modal closes. Once open, we must trap keyboard focus inside the modal boundaries.
Let’s write a custom hook useFocusTrap to handle finding focusable elements and cycling through them when Tab or Shift+Tab is pressed.
import { useEffect, useRef } from 'react';
const FOCUSABLE_SELECTOR =
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
export function useFocusTrap(isOpen: boolean, onClose: () => void) {
const containerRef = useRef<HTMLDivElement>(null);
const previousActiveElement = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!isOpen) return;
// 1. Save current focus
previousActiveElement.current = document.activeElement as HTMLElement;
const container = containerRef.current;
if (!container) return;
// 2. Focus the first focusable element inside the modal
const focusableElements = container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
const firstElement = focusableElements[0];
if (firstElement) {
firstElement.focus();
} else {
container.focus(); // Fallback if no interactive elements
}
// 3. Handle keyboard events (Tab trapping & Escape to close)
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
return;
}
if (event.key === 'Tab') {
if (focusableElements.length === 0) {
event.preventDefault();
return;
}
const first = focusableElements[0];
const last = focusableElements[focusableElements.length - 1];
if (event.shiftKey) {
// If shift + tab and focus is on first element, wrap to last
if (document.activeElement === first) {
last.focus();
event.preventDefault();
}
} else {
// If tab and focus is on last element, wrap to first
if (document.activeElement === last) {
first.focus();
event.preventDefault();
}
}
}
};
document.addEventListener('keydown', handleKeyDown);
// 4. Cleanup: Restore focus when modal unmounts/closes
return () => {
document.removeEventListener('keydown', handleKeyDown);
if (previousActiveElement.current) {
previousActiveElement.current.focus();
}
};
}, [isOpen, onClose]);
return containerRef;
}
Step 3: Hiding Background Content with the inert Attribute
Screen readers can still navigate through background DOM nodes even if they are visually covered by a backdrop, unless those nodes are explicitly hidden. Historically, developers used aria-hidden="true" on the root app container. However, aria-hidden does not prevent keyboard focus from bleeding into background elements.
Today, modern browsers support the inert attribute. When an element is marked inert:
- It and all its descendants are excluded from the accessibility tree.
- The browser ignores user input events (clicks, focus) for that subtree.
Let’s write a utility hook to toggle inert on everything outside our modal (typically #root or main).
import { useEffect } from 'react';
export function useInertBackground(isOpen: boolean, rootSelector: string = '#root') {
useEffect(() => {
if (!isOpen) return;
const rootElement = document.querySelector(rootSelector);
if (!rootElement) return;
rootElement.setAttribute('inert', 'true');
return () => {
rootElement.removeAttribute('inert');
};
}, [isOpen, rootSelector]);
}
Step 4: Locking Body Scroll
When a modal is open, scrolling the background page creates a jarring user experience. We can prevent this by toggling overflow: hidden on the document.body.
import { useEffect } from 'react';
export function useScrollLock(isOpen: boolean) {
useEffect(() => {
if (!isOpen) return;
const originalOverflow = document.body.style.overflow;
const scrollBarWidth = window.innerWidth - document.documentElement.clientWidth;
// Lock scroll and add right padding to prevent layout shift from scrollbar disappearance
document.body.style.overflow = 'hidden';
if (scrollBarWidth > 0) {
document.body.style.paddingRight = `${scrollBarWidth}px`;
}
return () => {
document.body.style.overflow = originalOverflow;
document.body.style.paddingRight = '';
};
}, [isOpen]);
}
Step 5: Putting It All Together
Now, let’s combine our hooks into the final, complete <Modal /> component.
import React from 'react';
import { createPortal } from 'react-dom';
import { useFocusTrap } from './useFocusTrap';
import { useInertBackground } from './useInertBackground';
import { useScrollLock } from './useScrollLock';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
export const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children }) => {
const containerRef = useFocusTrap(isOpen, onClose);
useInertBackground(isOpen, '#root');
useScrollLock(isOpen);
if (!isOpen) return null;
return createPortal(
<div className="modal-backdrop" onClick={onClose}>
<div
ref={containerRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabIndex={-1}
className="modal-container"
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
);
};
Essential CSS Styling
To make our modal visually clear, add the following baseline styles:
.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;
padding: 1rem;
}
.modal-container {
background: #ffffff;
border-radius: 8px;
width: 100%;
max-width: 500px;
padding: 1.5rem;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
outline: none; /* Focus outline is handled via semantic state or custom styles if needed */
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.close-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
}
Conclusion
Building an accessible modal goes far beyond visual styling. By combining React Portals, keyboard event listeners for focus trapping, the native inert attribute for screen reader isolation, and clean scroll-locking mechanics, you ensure that keyboard and screen reader users have a first-class experience.
Drop this pattern into your component library, and you’ll never have to worry about accessibility audits flagging trapped focus issues again!