Solving the Modal Focus Trap with Reach UI Primitives
Stop letting keyboard users 'tab out' of your modals. Learn how to use Reach UI's FocusScope and Dialog primitives to implement professional WAI-ARIA focus traps in React.
04 May 2026, 16:35 UTC

The 'Tabbing Out' Problem
When you build a custom modal or dialog in React, it is easy to forget that a visual overlay does not stop a keyboard user. Without a focus trap, a user pressing the Tab key will eventually move their focus out of the modal and into the background page. This creates a confusing experience for screen reader users and keyboard navigators who are effectively interacting with a hidden page while a modal is visually blocking their view.
The most reliable way to solve this is not by writing a custom useEffect hook to track focus, but by using accessibility primitives that implement the WAI-ARIA (Web Accessibility Initiative - Accessible Rich Internet Applications) patterns. Reach UI provides these as modular components that handle the complex logic of focus management while leaving the styling to you.
Managing Focus with FocusScope
The core of a focus trap is the FocusScope. This component ensures that when a user tabs through a set of elements, the focus wraps back to the first element in the scope rather than escaping to the browser's address bar or the background content.
By wrapping your modal content in a FocusScope, you create a boundary. This is critical for:
- Modals: Ensuring the user completes a task before returning to the main app.
- Sidebars: Keeping navigation focused within the menu.
- Complex Forms: Preventing accidental navigation to the footer during a multi-step process.
Implementing an Accessible Dialog
While FocusScope handles the trap, the Dialog component from Reach UI combines focus trapping with focus restoration. When a dialog opens, it moves focus inside; when it closes, it automatically returns focus to the element that triggered it. This prevents the user from "getting lost" on the page after closing a popup.
Worked Example: A Basic Accessible Modal
To implement this, you first need to install the specific primitive to keep your bundle size small:
# Run in your project terminal
npm install @reach/dialog
Here is a implementation pattern for a simple confirmation dialog:
import React, { useState } from 'react';
import Dialog from '@reach/dialog';
const ConfirmationModal = () => {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
{/* The trigger element that will receive focus back after closing */}
<button onClick={() => setIsOpen(true)}>
Delete Account
</button>
<Dialog
isOpen={isOpen}
onClose={() => setIsOpen(false)}
ariaLabel="Account Deletion Confirmation"
>
<div className="modal-content">
<h3>Are you sure?</h3>
<p>This action cannot be undone.</p>
<button onClick={() => setIsOpen(false)}>Cancel</button>
<button onClick={() => { /* handle delete */ setIsOpen(false); }}>
Confirm Delete
</button>
</div>
</Dialog>
</div>
);
};
Verification and Testing
To verify the implementation is working correctly, perform these three checks:
- The Tab Test: Open the dialog and press Tab repeatedly. The focus should cycle through the "Cancel" and "Confirm" buttons and never move to the background page.
- The Escape Test: Press the
Esckey. The dialog should close immediately. - The Restoration Test: After closing the dialog, press Tab once. The focus should be back on the "Delete Account" trigger button, not at the top of the page.
Trade-offs and Limitations
Reach UI takes a "composition-first" approach, meaning it provides the behavior but no default CSS. While this prevents style conflicts, it means you must manually handle the visual overlay and centering. If you use a CSS framework, you will need to apply your own classes to the Dialog children.
Additionally, be mindful of bundle size. Instead of installing a monolithic library, import only the primitives you need (e.g., @reach/dialog or @reach/focus-scope) to avoid adding unnecessary code to your production build.
Actionable Closing
Stop managing modal focus with manual document.activeElement calls. By implementing @reach/dialog, you ensure your application meets WAI-ARIA standards for keyboard navigation with minimal boilerplate. Start by auditing your current modals: if a user can tab into the background while a modal is open, replace that component with a Reach primitive.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.