How to Build an Accessible React Modal with Portals

Build an accessible React modal with createPortal, a dialog role, focus management, and Escape to close. See the full pattern in steps.

7 min read

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.

App.jsxApp.jsx
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.

App.jsxApp.jsx
<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.

App.jsxApp.jsx
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.

App.jsxApp.jsx
<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.

App.jsxApp.jsx
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.

App.jsxApp.jsx
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

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.
RunePowered by Rune AI

Frequently Asked Questions

Why render a modal through a portal?

A portal moves the dialog markup to the end of the document body, so ancestor styles such as overflow hidden or a transformed parent cannot clip or reposition it.

Does aria-modal trap keyboard focus by itself?

No. aria-modal tells assistive technology that content outside is inert, but you still need JavaScript to move focus inside and cycle Tab within the dialog.

Should the Escape key close every modal?

Yes for a standard modal. The WAI-ARIA dialog pattern says Escape closes the dialog, and a visible close button should exist for mouse and touch users.

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.