Reach UI Dialog: Accessible React Modals Without Manual Focus Management
Reach UI Dialog traps focus, portals to document.body, and applies ARIA automatically—but it's in maintenance mode. Learn when to use it and how to verify behavior.
01 Mar 2026, 13:50 UTC

The practical answer
You need a React modal that traps keyboard focus, returns focus to the trigger button when it closes, prevents background scrolling, and exposes the correct ARIA roles—without writing custom focus-management code. Reach UI Dialog (@reach/dialog, v0.18.x) provides those behaviors out of the box. It renders the overlay and content through a React Portal to document.body, applies role="dialog" and aria-modal="true" automatically, and cycles Tab focus among focusable children inside the dialog.
Useful takeaway: Use Reach Dialog only if you are maintaining an existing Reach codebase. The library is in maintenance mode, with its last release in 2022 and no new features or security patches expected. For new projects, use Radix UI Dialog, Headless UI, or ARIA Kit instead.
How the component works
A React Portal renders DOM nodes outside the normal React tree. Reach Dialog uses this so the modal escapes CSS stacking contexts and overflow:hidden containers that would otherwise clip it. When the dialog opens, focus moves to the first focusable child—typically a button—and the Tab key cycles only within the dialog content. Pressing Escape closes the dialog through the onDismiss handler. On close, focus returns to the element that triggered the dialog.
ARIA attributes are applied automatically: role="dialog", aria-modal="true", aria-labelledby pointing to the dialog title, and aria-describedby for descriptive text. On the server, the component renders null because there is no portal target, which prevents hydration mismatches in frameworks such as Next.js or Remix.
Worked configuration
This example renders a confirmation dialog. The Cancel button receives initial focus. Clicking Cancel or Delete calls close, which returns focus to the trigger.
import React from "react";
import { Dialog } from "@reach/dialog";
import "@reach/dialog/styles.css";
function ConfirmDialog({ isOpen, onClose, onConfirm }) {
return (
Delete item?
This action cannot be undone.
Cancel
Delete
);
}
export default function Page() {
const [open, setOpen] = React.useState(false);
return (
<>
setOpen(true)}>Open dialog
setOpen(false)}
onConfirm={() => {
setOpen(false);
}}
/>
);
}
The Dialog.Title and paragraph receive ids so aria-labelledby and aria-describedby can point to them. If you need focus on a different element, pass initialFocusRef to a ref attached to that element. Use finalFocusRef to override where focus returns after close, which is useful for multi-step wizards.
Installation and version assumptions
Run this command in your project root:
npm install @reach/dialog@0.18.0 react react-domWhere to run: terminal at the project root. Required permissions: standard file-write access for node_modules and package.json. Placeholders: replace @reach/dialog@0.18.0 with another version only if your lockfile requires it. Expected checks: @reach/dialog appears in node_modules and package.json, and the application builds without peer-dependency errors. Risks: the package is in maintenance mode, and React 18 or later may emit peer-dependency warnings. Rollback: run npm uninstall @reach/dialog and remove the import statements.
Verification checklist
- Open the dialog and confirm focus lands on the first focusable child, such as the Cancel button.
- Press Tab repeatedly and verify focus cycles only among elements inside Dialog.Content.
- Press Escape and confirm the dialog closes through onDismiss.
- Close the dialog and confirm focus returns to the trigger button.
- Open Chrome DevTools, select the Elements panel, and confirm Dialog.Content and Dialog.Overlay are direct children of document.body, not nested inside your React root.
- Run axe-core or Lighthouse on the open dialog and check for no violations related to role, aria-modal, focus order, or focus trapping.
- If using server-side rendering, confirm no hydration mismatch warnings appear and the dialog mounts only on the client.
Limits and common mistakes
- Maintenance mode: No security patches or new features are expected. Treat @reach/dialog as a legacy dependency.
- Portal target: The default portal target is document.body. If your application uses a shadow DOM root or multiple React roots, supply a custom portal prop pointing to an HTMLElement, or the portal may fail to attach.
- Nested dialogs: Opening a second dialog while the first is open creates two focus traps. Coordinate state so the first dialog closes before the second opens.
- Overlay click-to-close: This behavior is opt-in. You must attach onDismiss to Dialog.Overlay; otherwise users who cannot use Escape may be trapped.
- TypeScript refs: initialFocusRef and finalFocusRef expect React.RefObject. Passing a callback ref or null causes type errors without a runtime failure.
Migration to Radix UI Dialog
Radix UI Dialog offers the same behavior with an actively maintained composition API. The main differences are prop names and component structure.
| Reach UI Dialog | Radix UI Dialog | Notes |
|---|---|---|
| isOpen | open | Boolean state for open/closed. |
| onDismiss | onOpenChange | Receives the next boolean state. |
| Dialog.Overlay | Dialog.Overlay | Click-to-close is handled through onOpenChange. |
| Dialog.Content | Dialog.Content | Requires Dialog.Portal in Radix to render outside the root. |
| Dialog.Title | Dialog.Title | ARIA labeling is wired automatically. |
If you migrate, replace imports, rename isOpen/onDismiss to open/onOpenChange, and wrap Dialog.Content with Dialog.Portal. Verify focus trapping and focus restoration after the change using the same checklist above.
Practical result check
After rendering the dialog, open DevTools and run this check in the Console:
document.body.contains(document.querySelector('[role="dialog"]'))Expected result: true. If false, the dialog did not portal to document.body. Also confirm that pressing Tab does not move focus to elements behind the overlay.