Building an Accessible Modal with @reach/dialog
Compose DialogOverlay and DialogContent for a focus-safe modal. Learn the mechanism, worked example, common mistakes, and verification steps for @reach/dialog.
12 Oct 2025, 09:08 UTC

The problem: a modal is a focus contract, not just a styled box
When you open a modal dialog, you are making a promise to keyboard and screen reader users: focus moves into the dialog, stays inside until the dialog closes, and returns to whatever opened it. Hand-rolling this with a plain div and a z-index usually breaks that promise. The useful answer is to compose @reach/dialog's DialogOverlay and DialogContent, control the isOpen prop, and funnel every close action through a single onDismiss callback.
The mechanism: one overlay, one content surface, one dismissal path
Reach UI's dialog package follows a specific composition pattern. DialogOverlay is the outer wrapper: it portals the dialog to the document body, manages focus containment, and listens for dismissal events. DialogContent is the inner surface where your interactive elements live. You do not render DialogContent on its own, and you do not build your own overlay div. Instead, you pass isOpen and onDismiss to the overlay, and give the content an accessible name.
Here is a minimal confirmation modal. Run this in a React project where you have already installed the package.
// Terminal: run in your project root
// npm install @reach/dialog
// Requires write access to node_modules and your package.json.
import { DialogOverlay, DialogContent } from "@reach/dialog";
import { useState } from "react";
function DeleteConfirmation() {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>Delete item</button>
<DialogOverlay isOpen={open} onDismiss={() => setOpen(false)}>
<DialogContent aria-label="Delete item">
<p>This action cannot be undone.</p>
<button onClick={() => { /* perform delete */ setOpen(false); }}>
Delete
</button>
<button onClick={() => setOpen(false)}>Cancel</button>
</DialogContent>
</DialogOverlay>
</>
);
}
In this example, the trigger button sits outside the dialog subtree. That placement matters because Reach is expected to restore focus to the previously focused element when the dialog closes; if the trigger is inside the dialog, it will be unmounted before focus can return to it.
When open becomes true, DialogOverlay portals the content to the end of document.body, moves focus into DialogContent, and traps Tab navigation inside the dialog. Pressing Escape or clicking the overlay triggers onDismiss, which sets open back to false. Because isOpen is controlled by your state, the dialog unmounts and focus returns to the trigger. The aria-label gives the dialog an accessible name so screen readers announce what the modal is for.
What Reach handles and what you still own
Reach supplies the dialog surface and the dismissal path: focus containment, Escape handling, outside-click detection, and focus restoration. It does not style the modal, write your copy, or prove that background content is inert to assistive technology. The portal removes the dialog from your component tree's DOM position, but you should still verify background-content behavior with your own accessibility checker and screen reader tests.
Scroll locking is another area where Reach does not make guarantees for every layout. If your page scrolls behind the modal, test whether the background scrolls and whether that creates a confusing experience. Nested dialogs, unsaved-form guards, and React StrictMode behavior also require explicit tests in your application.
Common mistakes that break the pattern
- Rendering
DialogContentwithoutDialogOverlay. The content component expects the overlay's context. Rendering it alone skips focus management and dismissal wiring. - Expecting
onDismisswithout controllingisOpen. IfisOpenis hardcoded totrueor not derived from state, callingonDismisshas nothing to change and the dialog stays open. - Nesting the close control inside another button. Interactive elements cannot be nested. If your close button is inside a larger clickable card, the browser will not deliver the click reliably and the accessibility tree becomes invalid.
- Relying on
z-indexinstead of the portal. The portal already moves the dialog to the document body. Fighting it with stacking contexts usually produces the opposite of what you want. - Treating Escape as save-and-close.
onDismissis a dismissal path, not a submission path. If the dialog contains a form with unsaved changes, intercept the dismissal and show a confirmation before calling your state setter.
Verifying the behavior in your app
Before shipping, run these checks in a browser. Open the dialog and press Tab repeatedly: focus should cycle through the dialog's focusable elements and never escape to the page behind it. Press Escape: the dialog should close and focus should return to the trigger button. Click the overlay outside the content: onDismiss should fire. Finally, run an accessibility checker or screen reader on the open dialog to confirm the accessible name is announced and to evaluate background-content behavior for your specific product requirements.
These checks require no special permissions beyond normal browser devtools access, but they do require a real keyboard and pointer. Automated tests that only assert on DOM presence will not catch focus-containment regressions.
Version compatibility and upgrade checks
Before starting new work, inspect the installed package's peerDependencies and confirm the React range it supports. Reach UI's dialog package has historically been sensitive to React version changes, and StrictMode can surface double-invocation issues during development. Because library maintenance status and peer ranges change, treat version compatibility as a current verification step rather than a fixed fact: check the installed package.json, run the keyboard and focus tests above with StrictMode enabled, and re-run them after any React or Reach upgrade.
If your dialog is not modal—for example, a non-modal popover, menu, or tooltip—choose a pattern designed for that behavior instead of forcing @reach/dialog into a role it was not built for.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.