Skip to content

Headless modal dialog comparison

How @rozie-ui/dialog compares to the existing modal / dialog libraries across the six frameworks. Like the slider, listbox, and OTP families, the dialog has no shared vanilla-JS engine — but unlike those, the "engine" it leans on is the browser platform itself: the native <dialog> element + showModal(). The modal landscape is overwhelmingly per-framework (Radix / Base UI / Ariakit on React, Headless UI on React + Vue, vue-final-modal on Vue, Angular CDK / Material on Angular, Reka UI on Vue, …), and almost all of them re-implement in JavaScript what the platform now gives for free: a top-layer surface, a scrim, a focus trap, and Esc handling. Rozie builds on the native element and ships the same idiomatic <Dialog> to all six frameworks: the same two-way open, the same close event with a reason, the same show/hide handle.

Research snapshot: 2026-08-10. The modal/dialog landscape moves quickly; treat the library names, framework coverage, and feature columns as of that date.

The libraries at a glance

Cell legend: = yes / out of the box · = no / not present · ⚠️ = partial or framework-specific. "Escapes ancestors via" is how the dialog defeats an ancestor's z-index / overflow: hidden / transform.

OptionFramework(s)Escapes ancestors viaFocus trapScrim sourceTwo-way openSame API on all 6 fw
Radix Dialog (@radix-ui/react-dialog)Reactportal → <body>⚠️ JS (FocusScope)styled <div>⚠️ open + onOpenChange
Base UI Dialog (@base-ui-components/react)Reactportal → <body>⚠️ JSstyled <div>⚠️ open + onOpenChange
shadcn/ui DialogReact (Vue via shadcn-vue)portal (wraps Radix / Reka UI)⚠️ JSstyled <div>⚠️ open + onOpenChange
Ariakit DialogReactportal → <body>⚠️ inert on the rest (no trap)styled <div>⚠️ open + setOpen
Headless UI DialogReact, Vueportal⚠️ JS focus-trapstyled <div>⚠️ open + onClose (v-model on Vue)❌ (2 fw)
vue-final-modal (v4)Vue<Teleport> → body⚠️ JSstyled overlayv-model
Angular CDK / Material DialogAngularCDK overlay container⚠️ CDK focus-trapCDK backdrop <div>⚠️ service open()DialogRef
Native <dialog>any (raw DOM)✅ native top layer✅ nativenative ::backdrop❌ imperative only
@rozie-ui/dialogall six✅ native top layer✅ nativenative ::backdropr-model:open

On its home framework each of these is a solid pick, and Rozie does not claim to out-feature Radix's composable parts, Base UI's primitive suite, or the CDK's overlay-positioning toolkit. The case for Rozie is consistency, coverage, and the native-<dialog> foundation: there is no modal component that spans every framework with the same API (Radix is React-only; its model has a separate Vue port in Reka UI, formerly Radix Vue, and a React successor in Base UI from ex-Radix engineers; separate packages with separate APIs), and almost none of the incumbents use the platform's top layer; they portal/teleport into <body> and re-build the focus trap and scrim in JavaScript. Rozie gives them all the same <Dialog>, sitting on the native element.

Top layer vs. portal: the design choice

The deepest decision in a modal component is how it escapes its ancestors' z-index, overflow: hidden, and transform contexts. Two camps:

  • Portal / teleport into <body> (Radix, Base UI, shadcn, Ariakit, Headless UI, vue-final-modal, Angular CDK): the dialog DOM is relocated to a container at the end of <body> so no ancestor can clip or stack it. It works, but it means the component owns a portal implementation, a scrim element, a JS focus-trap (Ariakit instead marks the rest of the page inert), scroll-lock, and the focus-restore dance. And the relocated node lives outside its logical place in the tree, a known footgun for event bubbling, context providers, and form ownership.
  • The native top layer (native <dialog>, Rozie): showModal() promotes the element to the browser's top layer, painted above all page content regardless of z-index / overflow / transform, with no DOM relocation. The element stays exactly where you authored it. The scrim is the real ::backdrop pseudo-element, the rest of the page is made inert by the UA, the focus trap and Esc handling are the UA's, and focus restoration to the previously-focused element is automatic.

Rozie picks the native-top-layer camp deliberately. The highest-value, hardest-to-get-right surface (top-layer stacking, the focus trap, focus restoration, Esc) is the browser's, while Rozie owns the author-side API: the two-way open, the open↔native reconcile, the backdrop/escape close policy, and optional scroll-lock.

Fully controlled, one close funnel

Most modal components expose a tangle of close paths (an onOpenChange, an overlay onClick, an Esc handler, an X button), each of which the consumer wires separately. @rozie-ui/dialog keeps one: open is the sole model: true prop, and every dismiss (backdrop click, Escape, programmatic hide()) funnels through a single site that writes open = false and emits close with a reason. That is what keeps the component fully controlled, and two-way bound: the binding is always in sync with what's on screen, and the reason tells you why it closed without extra handlers.

When to use which

  • Radix / Base UI / shadcn / Ariakit (React): reach for these when you need composable, individually-stylable parts (Dialog.Trigger / Title / Description / Close) with wired ARIA, or you're already inside that design system. Ariakit is worth a look if you prefer inert-based isolation over a JS focus-trap.
  • Headless UI / vue-final-modal: a mature, battle-tested modal when you're committed to a single framework: React + Tailwind (Headless UI) or Vue (either). vue-final-modal also ships a useModal() programmatic/dynamic API.
  • Angular CDK / Material Dialog: the pick on Angular when you want to open dialogs imperatively from a service (dialog.open(Component) → a DialogRef with an awaited result and injected data), or you need the CDK's overlay toolkit for non-modal, positioned popovers/tooltips/menus.
  • Raw native <dialog>: no framework, zero dependencies, and you're happy to hand-wire the open-state, backdrop-click, and close-reason logic yourself.
  • @rozie-ui/dialog: you ship across two or more of the six frameworks (or to Lit / Solid, which the incumbents barely serve) and want the same <Dialog>, a real two-way open, and the platform's top-layer / focus-trap / focus-restore behaviour, without maintaining a wrapper per framework.

Where Rozie wins today

  • First-class packages everywhere, including Lit / web components, which have no mainstream modal component at all, and Solid, where modals are mostly hand-rolled. Both are categories the incumbents barely serve.
  • The same component surface everywhere. Where the ecosystem offers a different library per framework (many APIs, many scrims, many focus-trap implementations), @rozie-ui/dialog is one <Dialog> with the same open model, the same close event with its reason, and the same show / hide handle: one component to learn, document, and migrate across your stack.
  • A real two-way open. r-model:open reads and writes, with no onOpenChange → setState glue.
  • The platform does the hard part. Top-layer rendering with no portal, a native ::backdrop, a real focus trap, Esc-to-dismiss, an inert background, and focus restoration come from <dialog> + showModal(), not re-implemented in JavaScript, so there is far less to go wrong (and nothing to ship).
  • Zero-config styling that re-skins to any design system. Every cosmetic value is a --rozie-dialog-* CSS custom property with a built-in fallback, plus ready-made token bridges for shadcn/ui, Material 3, and Bootstrap 5.

What Rozie defers

  • Composable, unstyled parts. Radix, Base UI, and Ariakit expose Dialog.Trigger / Title / Description / Close as separate primitives with wired ARIA. @rozie-ui/dialog is a single element with one default slot; you supply the heading and buttons yourself (and point ariaLabelledby at your title).
  • Non-modal / positioned overlays. The CDK overlay toolkit (and Floating-UI-based libraries) position popovers, tooltips, and dropdowns with flexible anchoring strategies. Rozie's dialog is specifically the modal case (showModal() + top layer); it is not a general overlay-positioning engine, popover, or drawer kit.
  • Imperative "open a dialog from a service". Angular CDK's Dialog.open(Component) (and vue-final-modal's useModal()) spawn dialogs imperatively with injected data and a result promise/ref. Rozie's dialog is declarative-first (r-model:open + slot content); the show() / hide() handle covers imperative open/close but not "render an arbitrary component and await its result".
  • Stacked / nested modals & scroll-lock edge cases. The native top layer stacks multiple open dialogs correctly, but Rozie's scroll-lock is a simple <html> overflow: hidden; it does not yet compensate for scrollbar-gutter shift or coordinate across several simultaneously-open dialogs the way some mature libraries do.
  • @rozie-ui/dialog is pre-1.0 and younger and less battle-tested than the established per-framework libraries; the full prop / event / handle surface is documented in the showcase + API reference. It also inherits the native <dialog> browser-support floor: Baseline "widely available" since March 2022 (Chrome/Edge, Firefox 98+, Safari 15.4+), so a polyfill is needed only for pre-2022 engines.

Try it

The @rozie-ui/dialog showcase + API reference documents the @rozie-ui/dialog-* packages — one pre-compiled, per-framework install (npm i @rozie-ui/dialog-react, etc.). There is no engine to import and no required CSS: the native-<dialog> behaviour and a fully-tokenised skin ship inside the component, with optional one-line theme bridges for shadcn/ui, Material 3, and Bootstrap 5. The live demo runs the real Vue package in the page.

Cross-references

Pre-1.0 — APIs may change between minor versions.