Accessible React Modals: Radix UI vs Building from Scratch
Building an accessible modal seems simple until you test with a keyboard. Here's what goes wrong with hand-rolled modals and why Radix-based libraries get it right.
By Ninna UI Team
A modal dialog is the textbook example of why accessibility matters. It looks simple — an overlay with content — but the interaction details are where hand-rolled implementations quietly fail users.
What an accessible modal must do
- Trap focus inside the modal while open (Tab doesn't escape to the page behind).
- Move focus to the modal (or first focusable element) on open.
- Restore focus to the trigger element when closed.
- Close on Escape key.
- Lock body scroll to prevent background scrolling.
- Set correct ARIA: role="dialog", aria-modal="true", aria-labelledby.
- Close when clicking the backdrop (optionally).
If you've ever opened a modal, pressed Tab, and watched focus disappear to the page behind it, you've experienced a focus trapping failure. It's one of the most common accessibility bugs in web apps.
Why building from scratch is risky
Each of those requirements has edge cases: What if the modal has no focusable elements? What if Shift+Tab is pressed at the first element? What if a new element is added to the modal dynamically? What about screen reader virtual buffers that don't respect z-index?
The WAI-ARIA dialog spec runs for pages. Getting every edge case right is tedious and hard to test. This is why most hand-rolled modals have at least one accessibility bug.
How Radix UI handles it
Radix UI's Dialog primitive implements all of the above correctly, tested across thousands of apps. It handles focus trapping, restoration, Escape, scroll lock, and ARIA — you just provide the content.
Ninna UI wraps Radix's Dialog internally, so you get all of this without managing @radix-ui packages:
import { Modal } from "@ninna-ui/overlays";
import { Button } from "@ninna-ui/primitives";
<Modal>
<Modal.Trigger asChild>
<Button>Open Dialog</Button>
</Modal.Trigger>
<Modal.Content>
<Modal.Header>Delete Account</Modal.Header>
<Modal.Body>Are you sure? This cannot be undone.</Modal.Body>
<Modal.Footer>
<Button variant="ghost">Cancel</Button>
<Button color="danger">Delete</Button>
</Modal.Footer>
</Modal.Content>
</Modal>Focus trapping, restoration, Escape to close, scroll lock, and ARIA semantics all work out of the box. You write the content, Radix handles the interaction, and Ninna UI adds the styling.
The takeaway
Accessible modals are hard. Unless you have a specific reason to build from scratch, use a library that handles the interaction correctly. Your users — especially keyboard and screen reader users — will thank you.