@radix-ui/react-dialog, @reach/dialog, and react-modal are all solutions for implementing modal dialogs in React applications. @radix-ui/react-dialog is a modern, headless component library that provides unstyled, accessible primitives for building custom dialogs. @reach/dialog was a pioneering accessible component suite that is now considered legacy, with most users migrating to Radix UI. react-modal is a classic, battle-tested library that offers a simpler, prop-driven API with default styles, though it lacks the composability of newer headless solutions.
Implementing a modal dialog might seem simple, but doing it correctly requires handling focus trapping, keyboard navigation, screen reader announcements, and scroll locking. @radix-ui/react-dialog, @reach/dialog, and react-modal all solve these problems, but they approach the task with different architectures and levels of flexibility. Let's break down how they compare in real-world engineering scenarios.
The way you compose a dialog varies significantly between these libraries. Modern React development favors compound components for flexibility, while older libraries often rely on single components with many props.
@radix-ui/react-dialog uses a compound component pattern. You build the dialog by assembling primitives like Trigger, Portal, Overlay, and Content. This gives you full control over the DOM structure.
import * as Dialog from '@radix-ui/react-dialog';
function RadixModal() {
return (
<Dialog.Root>
<Dialog.Trigger>Open Modal</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content>
<h2>Dialog Title</h2>
<p>Dialog content here.</p>
<Dialog.Close>Close</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
@reach/dialog also uses compound components but with a simpler, flatter structure. It relies on specific subcomponents like DialogOverlay and DialogContent wrapped in a main Dialog component.
import { Dialog, DialogOverlay, DialogContent } from '@reach/dialog';
function ReachModal() {
return (
<Dialog aria-label="Modal">
<DialogOverlay />
<DialogContent>
<h2>Dialog Title</h2>
<p>Dialog content here.</p>
</DialogContent>
</Dialog>
);
}
react-modal uses a single component controlled by props. You pass content as children and manage state externally. This is straightforward but less flexible for custom layouts.
import Modal from 'react-modal';
function ReactModalExample({ isOpen, onClose }) {
return (
<Modal isOpen={isOpen} onRequestClose={onRequestClose}>
<h2>Dialog Title</h2>
<p>Dialog content here.</p>
<button onClick={onClose}>Close</button>
</Modal>
);
}
How much CSS work do you need to do? Headless libraries leave styling entirely to you, while older libraries might provide defaults.
@radix-ui/react-dialog is completely unstyled. You must apply all CSS classes, including positioning and animations. This ensures no style conflicts but requires more setup.
/* Radix requires manual CSS for overlay and content */
.DialogOverlay {
background-color: rgba(0, 0, 0, 0.5);
position: fixed;
inset: 0;
}
.DialogContent {
background: white;
border-radius: 8px;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
@reach/dialog provides minimal default styles focused on functionality rather than looks. You still need to override most styles to match your brand, but it handles basic positioning.
/* Reach has some defaults but usually needs overriding */
[data-reach-dialog-overlay] {
background: rgba(0, 0, 0, 0.5);
}
[data-reach-dialog-content] {
background: white;
padding: 2rem;
}
react-modal comes with basic default styles that make it work out of the box. You can override them using custom styles props, but you are fighting existing defaults sometimes.
// React Modal allows style overrides via props
<Modal
isOpen={isOpen}
style={{
overlay: { backgroundColor: 'rgba(0,0,0,0.5)' },
content: { borderRadius: '8px', top: '50%' }
}}
>
{/* Content */}
</Modal>
All three libraries handle the heavy lifting of WAI-ARIA attributes, but their rigor and configuration options differ.
@radix-ui/react-dialog implements the latest WAI-ARIA patterns. It automatically traps focus, handles escape keys, and restores focus when closed. You can customize these behaviors via props.
// Radix allows fine-tuning accessibility props
<Dialog.Content
onInteractOutside={(e) => e.preventDefault()} // Disable close on outside click
aria-describedby="description-id"
>
{/* Content */}
</Dialog.Content>
@reach/dialog was built with accessibility as a primary goal and remains robust. It manages focus and aria labels automatically, though configuration options are fewer than Radix.
// Reach requires aria-label for accessibility
<Dialog aria-label="Confirmation">
<DialogContent>
{/* Content */}
</DialogContent>
</Dialog>
react-modal supports accessibility features like focus trapping and aria labels, but you often need to configure them manually via props to ensure compliance.
// React Modal requires explicit app element setting for accessibility
Modal.setAppElement(document.getElementById('root'));
<Modal
isOpen={isOpen}
ariaHideApp={true}
aria-label="Confirmation"
>
{/* Content */}
</Modal>
Modals often need fade-ins or slide-ups. The ease of implementing these varies by library.
@radix-ui/react-dialog works seamlessly with CSS animations or libraries like Framer Motion. You control the animation state directly.
// Radix supports CSS animations via data-state attributes
<Dialog.Content className="animate-in fade-in zoom-in">
{/* Content */}
</Dialog.Content>
@reach/dialog supports animations but requires careful handling of the onDismiss lifecycle to ensure animations complete before unmounting.
// Reach animations often require wrapping components
<Dialog>
<AnimatedDialogContent>
{/* Content */}
</AnimatedDialogContent>
</Dialog>
react-modal has built-in support for CSS transitions via className props for when the modal opens or closes.
// React Modal has specific props for transition classes
<Modal
isOpen={isOpen}
className="modal-content"
overlayClassName="modal-overlay"
>
{/* Content */}
</Modal>
Long-term support is critical for architectural decisions. You need to know if a library will be maintained in the future.
@radix-ui/react-dialog is actively maintained and part of the larger Radix UI ecosystem. It is the successor to Reach UI and receives regular updates for React compatibility and features.
@reach/dialog is considered legacy. While still functional, it is no longer the primary focus of its original creators. New projects should avoid it in favor of Radix UI to ensure future compatibility.
react-modal is stable and widely used but sees infrequent updates. It is a finished product that works well for standard use cases but may not keep pace with new React patterns like Server Components.
| Feature | @radix-ui/react-dialog | @reach/dialog | react-modal |
|---|---|---|---|
| Architecture | Compound Components | Compound Components | Single Component |
| Styling | Unstyled (Headless) | Minimal Defaults | Default Styles Included |
| Accessibility | High (Modern WAI-ARIA) | High (Legacy Standard) | Moderate (Configurable) |
| Maintenance | Active | Legacy / Superseded | Stable / Low Activity |
| Animation | Manual / CSS / Framer | Manual / Wrapper | Built-in Transition Props |
For modern React applications, @radix-ui/react-dialog is the clear choice. It offers the best balance of accessibility, flexibility, and long-term support. The component composition model fits well with contemporary React patterns and design systems.
Use react-modal only for quick prototypes or legacy maintenance where refactoring to a new library is not feasible. Its prop-driven API is simple, but it lacks the extensibility needed for complex UIs.
Avoid starting new projects with @reach/dialog. While it was a pioneer in accessible React components, the ecosystem has moved to Radix UI. Migrating early saves technical debt later.
Bottom Line: If you care about accessibility and customization, go with Radix. If you need something working in five minutes with minimal CSS, React Modal works ā but know the trade-offs.
Choose @radix-ui/react-dialog for new projects that require full design control and strict accessibility compliance. It is the current industry standard for headless UI, offering robust focus management and keyboard navigation without imposing visual styles. This package is ideal when you need a dialog that integrates seamlessly with your existing design system.
Choose @reach/dialog only if you are maintaining an existing legacy codebase that already depends on Reach UI. It is no longer the primary recommendation for new development, as the original creators have moved their focus to Radix UI. Migrating to Radix is advised for long-term support and access to modern React features.
Choose react-modal if you need a quick, simple solution with minimal setup and default styling is acceptable. It is well-suited for internal tools or older projects where introducing a complex component composition model is not desirable. However, be aware that it offers less flexibility for custom animations and advanced accessibility tweaks compared to headless alternatives.
react-dialogView docs here.