React Modal / Dialog: Headless UI, Radix UI and the Vanilla Way
Headless UI vs Radix UI vs rolling your own React modal — here's what actually matters, with real code and honest trade-offs for 2026.
Why Modal Implementation Still Trips People Up in 2026
You'd think modals were a solved problem. They're not. Every React project eventually has that one dialog that traps keyboard focus in the wrong element, doesn't close on Escape, or renders behind a z-index war you didn't start. It's frustrating because the surface area looks small — it's just a box that pops up — but the accessibility spec for role="dialog" is surprisingly long.
The HTML <dialog> element has solid browser support now, and the native showModal() API handles focus trapping and the backdrop for free. That said, wiring it into React's lifecycle without a library takes more care than most tutorials admit. You've got useRef, useEffect, and event cleanup to juggle, and that's before you add enter/exit animations.
Honestly, the choice between a headless library and rolling your own comes down to one question: does your design system need pixel-perfect control over every detail, or do you need something production-ready by Thursday? Both answers are valid. Let's look at what each path actually costs.
The Vanilla React Approach: `<dialog>` + useRef
The native <dialog> element is genuinely good. Since Chrome 98 and Firefox 98 shipped full support, you can skip third-party code for simple use cases. Focus trapping, the ::backdrop pseudo-element, and Escape-to-close all work out of the box. Here's a minimal implementation that you can drop into any project today:
import { useEffect, useRef } from 'react';
export function Modal({ open, onClose, children }) {
const ref = useRef(null);
useEffect(() => {
const dialog = ref.current;
if (!dialog) return;
if (open) {
dialog.showModal();
} else {
dialog.close();
}
}, [open]);
useEffect(() => {
const dialog = ref.current;
const handleCancel = (e) => {
e.preventDefault();
onClose();
};
dialog?.addEventListener('cancel', handleCancel);
return () => dialog?.removeEventListener('cancel', handleCancel);
}, [onClose]);
return (
<dialog
ref={ref}
style={{ padding: '24px', borderRadius: '12px', border: 'none' }}
onClick={(e) => e.target === ref.current && onClose()}
>
{children}
</dialog>
);
}That onClick on the <dialog> element itself handles backdrop-click-to-close. The trick is that clicking the backdrop fires the event on the <dialog> node directly, not on any child. Worth noting: you still need to handle the cancel event (fired on Escape) separately if you want to sync the closed state back to React without calling dialog.close() twice.
Where this approach breaks down is animation. The <dialog> element opens and closes synchronously, so CSS transitions on opacity or transform only fire on open — the element disappears before the exit animation can play. You'd need to manage an isClosing state flag yourself. That's when a library starts to look very reasonable.
Radix UI Dialog: Accessibility Without the Ceremony
Radix UI's @radix-ui/react-dialog package is what I'd reach for on most production projects. It's headless — zero default styles — but it handles every accessibility requirement: aria-modal, aria-labelledby, aria-describedby, focus trapping, scroll locking, and portal rendering. You style it entirely with your own CSS or Tailwind classes.
import * as Dialog from '@radix-ui/react-dialog';
export function ConfirmModal({ open, onOpenChange, onConfirm }) {
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="dialog-overlay" />
<Dialog.Content
className="dialog-content"
aria-describedby={undefined}
>
<Dialog.Title>Are you sure?</Dialog.Title>
<p>This action cannot be undone.</p>
<div style={{ display: 'flex', gap: '8px', marginTop: '16px' }}>
<Dialog.Close asChild>
<button onClick={onConfirm}>Confirm</button>
</Dialog.Close>
<Dialog.Close asChild>
<button>Cancel</button>
</Dialog.Close>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}The Dialog.Portal renders into document.body, so z-index stacking context issues largely disappear. One more thing — Radix gives you forceMount on both Overlay and Content, which lets you keep the elements in the DOM during exit animations while controlling visibility with CSS data-state attributes (data-state="open" / data-state="closed").
In practice, Radix Dialog is the right call when you're building a design system that other developers will consume. The API surface is small, it composes well with Empire UI's existing component patterns, and the bundle cost (around 8 KB gzipped) is trivial.
Headless UI Dialog: The Tailwind-Native Option
If your project is already on Tailwind and you're using Headless UI from the Tailwind Labs team, the Dialog component there is a natural fit. The API is slightly different from Radix — it uses a render-prop-style Transition wrapper for animations, and it was built with Tailwind's animation utilities in mind.
The main trade-off vs Radix is composability. Radix's primitive-per-element model (Dialog.Root, Dialog.Trigger, Dialog.Content) maps better to building highly custom UIs where you need to split pieces across different components or render the trigger far from the content in the tree. Headless UI is more opinionated about co-location.
Quick aside: Headless UI v2 (released 2024) dropped the separate @headlessui/react peer dependency model and improved TypeScript types substantially. If you're on v1, the upgrade is worth it. The new transition prop on Dialog.Panel means you no longer need the wrapper Transition component for simple fade-ins.
Look, neither library is wrong. Radix is better for headless design systems. Headless UI is better if you're shipping Tailwind-heavy product UI and want fewer moving parts. Pick the one that fits your existing stack and stop agonizing.
Styling Your Dialog: From Flat to Glassmorphic
Once you've got the behavior nailed, styling is where you can actually have fun. The base CSS for a centered modal hasn't changed much — position: fixed, inset: 0, display: grid, place-items: center on the overlay, and max-width: 560px with width: calc(100% - 48px) on the content panel to keep it responsive at 375px viewport widths.
For something more visually distinctive, a glassmorphism treatment works well on dialogs. backdrop-filter: blur(20px) with a semi-transparent background (rgba(255,255,255,0.08)) and a 1px border (rgba(255,255,255,0.15)) gives you that frosted-glass look that pairs well with dark backgrounds. You can prototype this quickly with the glassmorphism generator — it'll output exactly the CSS you need.
.dialog-content {
background: rgba(255, 255, 255, 0.07);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 16px;
padding: 32px;
max-width: 560px;
width: calc(100% - 48px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.dialog-overlay {
background: rgba(0, 0, 0, 0.6);
position: fixed;
inset: 0;
}For bolder design systems, browse the components — the glassmorphism and neobrutalism component sets both include styled dialog variants you can adapt rather than starting from scratch. The box shadow generator is also handy for dialing in that layered depth effect on the panel.
Accessibility Checklist You'll Actually Remember
Here's what makes a dialog actually accessible, condensed to the things that come up in real audits. Focus must move into the dialog when it opens — specifically to the first focusable element or to the dialog container itself if aria-label is set. Focus must return to the trigger element when it closes. Tab and Shift+Tab must cycle only within the dialog while it's open.
The dialog container needs role="dialog" and aria-modal="true". It needs either aria-labelledby pointing to a visible heading inside it, or aria-label if there's no heading. Radix and Headless UI handle all of this automatically. With the vanilla <dialog> approach, the browser handles aria-modal and focus trapping for you, but you still need to add aria-labelledby manually.
One thing people skip: the close button needs an accessible name. <button>×</button> fails. Use aria-label="Close dialog" or wrap a visually hidden span. Sounds obvious, but it shows up in audits constantly.
Animations: Entry and Exit Without Pain
Animation is where most homegrown modal implementations fall apart. Getting the enter animation is easy — CSS transitions fire when the element appears. The exit animation requires keeping the element mounted during the transition, then removing it after. That's either a manual isClosing state flag or a library that handles it.
With Radix, you set forceMount and drive visibility with data-state CSS selectors. With Framer Motion, you wrap content in AnimatePresence and use motion.div for both the overlay and the panel. The Framer approach gives you more expressive animations (spring physics, shared layout transitions) at the cost of a larger dependency — around 50 KB gzipped for the full package, though the framer-motion/dist/framer-motion ESM bundle is tree-shakeable.
/* Radix-style data-state animation */
.dialog-content[data-state='open'] {
animation: dialogIn 150ms ease-out;
}
.dialog-content[data-state='closed'] {
animation: dialogOut 150ms ease-in;
}
@keyframes dialogIn {
from { opacity: 0; transform: translate(-50%, -48%) scale(0.96); }
to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
}
@keyframes dialogOut {
from { opacity: 1; transform: translate(-50%, -50%) scale(1); }
to { opacity: 0; transform: translate(-50%, -48%) scale(0.96); }
}That 150ms timing is deliberate — fast enough to feel snappy, slow enough that the user registers the state change. Anything above 200ms on a dialog starts to feel sluggish. Keep it tight.
FAQ
For simple cases, native <dialog> is fine — it handles focus trapping and Escape-to-close automatically. For production design systems with animations and nested portals, Radix UI saves you significant edge-case work.
Radix and Headless UI both handle this automatically via overflow: hidden on document.body. Vanilla implementations need document.body.style.overflow = 'hidden' on open and cleanup on close — don't forget the cleanup or you'll lock scroll permanently.
Technically, a modal blocks interaction with the rest of the page (via aria-modal="true" and focus trapping), while a dialog is just the visual overlay. In practice, people use the terms interchangeably and in React the implementation is identical either way.
Portal them all to document.body and manage z-index with a counter or a global stack. Radix handles this with its internal layer system — nested Dialog.Root components automatically stack correctly without manual z-index management.