Trapped in the Dialog: Building a Fully Accessible Modal Component in React
A deep dive into building a production-ready, highly accessible React modal component from scratch, mastering focus trapping, the inert attribute, escape key handling, and scroll locking.
Trapped in the Dialog: Building a Fully Accessible Modal Component in React
Modals are among the most common UI patterns on the modern web, yet they are notoriously difficult to implement correctly from an accessibility (a11y) standpoint. When a modal opens, it creates a new layer of interaction. Screen readers need to know the rest of the page is hidden, keyboard users must not be able to tab out of the dialog box, background content must not scroll, and pressing Escape should cleanly close the overlay.
While UI libraries like Radix, Headless UI, or Chakra provide these out-of-the-box, understanding how to build a modal from scratch using modern web APIs gives you total control, zero bloat, and deep insight into browser accessibility trees.
In this technical walkthrough, we will build a fully accessible modal component in React using TypeScript, leveraging modern features like the inert attribute, custom focus trapping, and React Portals.
The Anatomy of an Accessible Modal
Before writing code, let’s establish the requirements for a truly accessible modal:
- Semantic HTML & ARIA: The container must use
role="dialog",aria-modal="true", and reference a visible title viaaria-labelledby. - Focus Management:
- Focus must move inside the modal immediately upon opening.
- Focus must be trapped inside the modal (Tabbing past the last element loops back to the first; Shift+Tab from the first loops to the last).
- Focus must return to the element that triggered the modal when it closes.
- Background Isolation: Background content must be visually and semantically obscured using
aria-hiddenor the nativeinertattribute, and body scrolling must be locked. - Keyboard Interactivity: Pressing the
Escapekey must close the modal.
Step 1: Setting up the React Portal and Structure
Modals need to break out of standard layout stacking contexts (like overflow: hidden or z-index traps) in parent elements. We use ReactDOM.createPortal to render the modal at the end of the document.body.
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"
>
<h2 id="modal-title">{title}</h2>
{children}
<button onClick={onClose} aria-label="Close modal">
Close
</button>
</div>
</div>,
document.body
);
};
Step 2: Handling Escape Keys and Scroll Locks
Next, we need to handle global keyboard events (Escape) and prevent the background page from scrolling while the modal is open. We handle this cleanly within a useEffect hook.
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
};
// Lock background scroll
const originalOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
window.addEventListener('keydown', handleKeyDown);
return () => {
document.body.style.overflow = originalOverflow;
window.removeEventListener('keydown', handleKeyDown);
};
}, [onClose]);
Step 3: Hiding the Background with the inert Attribute
Screen readers and assistive technologies should not perceive background elements when a modal is active. Historically, developers applied aria-hidden="true" to every root sibling of the modal. Today, we have a much cleaner native solution: the inert attribute.
When an element is marked as inert, the browser ignores clicks, focus events, and screen reader announcements for that subtree.
useEffect(() => {
const rootElement = document.getElementById('root');
if (!rootElement) return;
rootElement.setAttribute('inert', '');
return () => {
rootElement.removeAttribute('inert');
};
}, []);
Tip: Ensure your React application root wrapper has an
id="root"(or wrap your application content in a designated layout container) so you can easily target and toggle theinertstate.
Step 4: Focus Management & Trapping
Focus trapping is the trickiest part of modal architecture. If a user presses Tab while focused on the last focusable element inside the modal, focus must cycle back to the first focusable element. Conversely, Shift + Tab on the first element must loop to the last.
We also need to save the element that had focus before the modal opened, and restore it upon closing.
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;
const modalElement = modalRef.current;
if (!modalElement) return;
// 2. Find all focusable elements inside the modal
const focusableSelectors = [
'a[href]',
'area[href]',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'button:not([disabled])',
'iframe',
'object',
'embed',
'[contenteditable]',
'[tabindex]:not([tabindex="-1"])'
];
const focusableElements = modalElement.querySelectorAll<HTMLElement>(
focusableSelectors.join(`, `)
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
// 3. Set initial focus inside modal
firstElement?.focus();
// 4. Trap focus handler
const handleTabKey = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
// If shift + tab and focused on first element, loop to last
if (document.activeElement === firstElement) {
lastElement?.focus();
e.preventDefault();
}
} else {
// If tab and focused on last element, loop to first
if (document.activeElement === lastElement) {
firstElement?.focus();
e.preventDefault();
}
}
};
modalElement.addEventListener('keydown', handleTabKey);
return () => {
modalElement.removeEventListener('keydown', handleTabKey);
// 5. Restore focus when modal unmounts
previousActiveElement.current?.focus();
};
}, [isOpen]);
if (!isOpen) return null;
return ReactDOM.createPortal(
<div className="modal-overlay">
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
className="modal-content"
>
<h2 id="modal-title">{title}</h2>
{children}
<button onClick={onClose} aria-label="Close modal">
Close
</button>
</div>
</div>,
document.body
);
};
Putting It All Together
Here is our complete, production-ready Modal component combining portal rendering, focus capture, scroll locking, inert background handling, and cleanup logic.
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 }) => {
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('inert', '');
const originalOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
if (modalElement) {
const focusableElements = modalElement.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
firstElement?.focus();
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
if (e.key === 'Tab') {
if (e.shiftKey && document.activeElement === firstElement) {
lastElement?.focus();
e.preventDefault();
} else if (!e.shiftKey && document.activeElement === lastElement) {
firstElement?.focus();
e.preventDefault();
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
if (rootElement) rootElement.removeAttribute('inert');
document.body.style.overflow = originalOverflow;
window.removeEventListener('keydown', handleKeyDown);
previousActiveElement.current?.focus();
};
}
}, [isOpen, onClose]);
if (!isOpen) return null;
return ReactDOM.createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
className="bg-white rounded-lg p-6 shadow-xl max-w-md w-full mx-4"
>
<div className="flex justify-between items-center mb-4">
<h2 id="modal-title" className="text-lg font-bold">
{title}
</h2>
<button
onClick={onClose}
className="text-gray-500 hover:text-gray-700"
aria-label="Close modal"
>
✕
</button>
</div>
<div className="mb-6">{children}</div>
</div>
</div>,
document.body
);
};
Conclusion
Building accessible UI components requires keeping multiple user interaction modalities in mind—mouse, keyboard, and screen readers. By leveraging semantic ARIA roles, the native inert attribute, precise DOM querying for focus trapping, and React Portals, you can construct a resilient modal component that works seamlessly for everyone without adding heavy external library dependencies.