An accessible React modal with portals floats above the page, traps attention, and stays usable from the keyboard and screen readers. The pattern has three parts: render the markup through a portal, add dialog semantics, and move focus in and out correctly.
Render the overlay in a portal
A modal lives inside the component that opens it in the React tree, but in the DOM it should sit at the end of document.body. A portal makes that split possible.
import { createPortal } from "react-dom";
export function Modal({ open, children }) {
if (!open) return null;
return createPortal(
<div className="modal">{children}</div>,
document.body
);
}The second argument to createPortal is the target DOM node, here document.body. React renders the children there while keeping them connected to the Modal component for props and events.
A real modal wraps those children in a dimmed backdrop and a dialog panel, so clicks on the backdrop can close it.
<div className="fixed inset-0 bg-black/40 p-4" onClick={onClose}>
<div className="mx-auto mt-20 max-w-sm rounded-xl bg-white p-6">
<h2>{title}</h2>
{children}
<button onClick={onClose}>Close</button>
</div>
</div>The outer div is the dimmed backdrop, and the inner div is the dialog. Clicking the backdrop closes the modal, while the Close button does the same from inside. Because the whole tree is passed to createPortal, no ancestor can clip it with overflow or transform.
Add the dialog semantics
The inner container needs three attributes so assistive technology understands what it is. The dialog keeps the backdrop around it and gains a role, a modality flag, and a labelled title.
The dialog also needs a ref so it can become a focus target later.
const dialogRef = useRef(null);That ref and a negative tabIndex go directly on the dialog div, alongside the ARIA attributes from the previous step, so the div can later receive focus programmatically even though it is not an interactive element by default.
<div
ref={dialogRef}
tabIndex={-1}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
className="mx-auto mt-20 max-w-sm rounded-xl bg-white p-6"
onClick={(event) => event.stopPropagation()}
>
<h2 id="modal-title" className="text-lg font-semibold">{title}</h2>
{children}
<button onClick={onClose}>Close</button>
</div>The role names the widget, aria-modal marks everything outside as inert to screen readers, and aria-labelledby points to the visible title. The stopPropagation on the inner div keeps a click on the dialog from reaching the backdrop handler, which would otherwise close it. The ref and tabIndex={-1} matter too: a div cannot normally receive focus, and the next section calls dialogRef.current.focus() to move keyboard focus inside.
Move focus inside and back
When the modal opens, keyboard focus should land inside the dialog, and when it closes, focus should return to the button that opened it. A ref and an Effect handle both.
useEffect(() => {
if (!open) return;
const previous = document.activeElement;
dialogRef.current?.focus();
return () => previous?.focus();
}, [open]);The Effect captures the trigger before it steals focus, focuses the dialog using the ref and tabIndex added in the previous section, and restores the trigger when the modal unmounts.
The Effect runs only when open changes, and its cleanup reverses the side effect. This is the same browser synchronization idea used across React focus management.
Close with Escape
Escape is the keyboard shortcut users expect. A second Effect listens on the window while the modal is open and removes the listener when it closes.
useEffect(() => {
if (!open) return;
function handleKeyDown(event) {
if (event.key === "Escape") onClose();
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [open, onClose]);Pressing Escape now closes the modal from anywhere. The cleanup removes the listener so the handler does not fire after the modal is gone, and the dependency array keeps the handler fresh.
What still needs your attention
A complete modal also cycles Tab within the dialog so focus cannot reach the page behind it. The dialog semantics alone do not trap focus, so build that loop or use a vetted library.
The semantic half of this article is part of the broader ARIA in React story, where the same rules apply to menus and tooltips.
Rune AI
Key Insights
- Render the dialog with createPortal into document.body.
- Set role dialog, aria-modal, and aria-labelledby on the container.
- Move focus inside on open and restore it on close.
- Close on Escape and on a backdrop click.
- Stop propagation so clicks inside do not close the dialog.
Frequently Asked Questions
Why render a modal through a portal?
Does aria-modal trap keyboard focus by itself?
Should the Escape key close every modal?
Conclusion
An accessible modal is a dialog rendered through a portal, with role dialog, aria-modal, a labelled title, focus moved inside on open, focus returned on close, and Escape to dismiss. The portal fixes clipping, and the ARIA and focus work make it usable by everyone.
More in this topic
How to Build a Dropdown Menu in React
Build a React dropdown menu with the ARIA menu button pattern. Handle open and close, keyboard arrows, and clicks outside the menu.
How to Animate React Components with Motion
Animate React components with the Motion library. Set up motion, add enter, hover, and exit animations, and respect reduced motion.
Headless UI Components Explained: Logic Without Locked Styling
Understand headless UI components and how libraries like Radix give you unstyled, accessible behavior that you style yourself.