Skip to content

Dialog — the cross-framework headless modal on the native <dialog> ​

Dialog is a headless, fully-accessible modal dialog built on the browser's native <dialog> element driven by showModal(). Every hard part of a modal (top-layer rendering, the scrim, the focus trap, Escape-to-dismiss, and focus restoration) comes from the platform. The same component ships for React, Vue, Svelte, Angular, Solid, and Lit.

Most modal libraries re-implement those hard parts in JavaScript and a portal/teleport to escape z-index, overflow, and transform ancestors. showModal() makes the portal obsolete: a modal <dialog> renders in the browser's top layer, above everything, with no DOM relocation. You get a real ::backdrop pseudo-element for the scrim, a native focus trap, the cancel event for Esc, and automatic focus return to the previously-focused element — none of which Rozie has to ship.

Rozie owns the author-side API: the two-way open binding, the open↔native reconcile (showModal() / close() guarded on the native open flag), the backdrop- and escape-close policy, optional <html> scroll-lock, and the token-themed skin. The component is fully controlled — open is the sole model: true prop, and every close path (backdrop, escape, programmatic) funnels through one site that writes open = false and emits close with a reason.

Every cosmetic value is a CSS custom property, so the dialog re-skins to any design system, with ready-made bridges for shadcn/ui, Material 3, and Bootstrap 5.

Solid-only debut

Today only @rozie-ui/dialog-solid is on npm; the other framework packages are in dogfooding.

The @rozie-ui/dialog packages ​

Dialog is designed to ship as six pre-compiled, per-framework packages, but today only @rozie-ui/dialog-solid is on npm — the rest are still in dogfooding. Install the published one for real use; the other rows below link to their workspace README for review:

PackageInstallREADME
@rozie-ui/dialog-reactnot yet published (dogfooding)react/README
@rozie-ui/dialog-vuenot yet published (dogfooding)vue/README
@rozie-ui/dialog-sveltenot yet published (dogfooding)svelte/README
@rozie-ui/dialog-angularnot yet published (dogfooding)angular/README
@rozie-ui/dialog-solidnpm i @rozie-ui/dialog-solidsolid/README
@rozie-ui/dialog-litnot yet published (dogfooding)lit/README

Each package carries only its framework peer (react + react-dom, vue, svelte, @angular/core + @angular/common + @angular/forms, solid-js, or lit + @lit-labs/preact-signals + @preact/signals-core).

Quick start ​

Two-way bind open to a boolean and put your content in the default slot. A click on a button flips open; a backdrop click, the Escape key, or a hide() call closes it and fires @close with the reason:

rozie
<components>
{
  Dialog: './Dialog.rozie',
}
</components>

<data>
{
  confirmOpen: false,
}
</data>

<template>
  <button @click="$data.confirmOpen = true">Delete file…</button>

  <Dialog r-model:open="$data.confirmOpen" ariaLabelledby="confirm-title" @close="onClose">
    <h2 id="confirm-title">Delete file?</h2>
    <p>This cannot be undone.</p>
    <button @click="$data.confirmOpen = false">Cancel</button>
    <button @click="remove()">Delete</button>
  </Dialog>
</template>

r-model:open is Rozie's two-way bind: the consumer owns the boolean, the component reconciles the native dialog to it (showModal() when it flips true, close() when it flips false), and writes false back on every dismiss — so open is always in sync with what's on screen, with no onClose → setState glue.

API ​

Props ​

NameTypeDefaultRuntime-updatable?Description
openBooleanfalseyes (via r-model)Whether the dialog is shown. The sole model: true prop — the component reconciles the native <dialog> to it (showModal() / close()) and writes false back on every dismiss.
disableBackdropCloseBooleanfalseyesOpt out of backdrop-click-to-dismiss. By default a click on the scrim (the <dialog> element itself, outside the content panel) closes with reason: 'backdrop'.
disableEscapeCloseBooleanfalseyesOpt out of Escape-to-dismiss. By default the native cancel event (Esc) closes with reason: 'escape'; the component preventDefault()s it so the close always flows through the open model.
disableScrollLockBooleanfalseyesOpt out of locking <html> scroll while open. By default document.documentElement overflow is set to hidden for the duration the dialog is shown.
initialFocusString | ElementnullyesWhat to focus when the dialog opens: a CSS selector matched inside the dialog content, or an Element. By default the native showModal() choice applies (the first autofocus element, else the first focusable one). A selector that matches nothing leaves the native choice in place.
ariaLabelStringnullyesAccessible name for the dialog when there is no visible title to point at (sets aria-label).
ariaLabelledbyStringnullyesid of the element that titles the dialog (sets aria-labelledby) — preferred over ariaLabel when a visible heading exists.

Events ​

EventDescription
closeFired whenever the dialog dismisses — backdrop click, Escape, or a programmatic hide(). Payload { reason } where reason is 'backdrop', 'escape', or 'programmatic'. The two-way open model is set to false on the same tick, so use this only to learn why it closed.

Imperative handle ​

Declared once in the source via $expose; obtained through each framework's native ref mechanism.

MethodDescription
showOpen the dialog imperatively (sets open = true; the native showModal() runs on the next reconcile).
hideClose the dialog imperatively (sets open = false and emits close with { reason: 'programmatic' }).

The verbs are show / hide, not open / close, on purpose: an open verb would collide with the open model prop, and a close verb would collide with the @close event (a compile error). show / hide are clear and collision-free, and unlike focus they are not inherited HTMLElement members, so there is no override warning on the Lit custom element.

Slots ​

SlotDescription
(default)The dialog content — your heading, body, and action buttons. It renders inside a .rozie-dialog-panel wrapper; backdrop clicks land on the <dialog> element itself (outside the panel), so clicks inside your content never close the dialog.

Theming ​

Every cosmetic value the component renders is a --rozie-dialog-* CSS custom property with a built-in fallback, so it works with zero configuration yet is completely re-skinnable. Override tokens at any ancestor scope:

css
.rozie-dialog {
  --rozie-dialog-radius: 1rem;
  --rozie-dialog-bg: #0b1020;
  --rozie-dialog-color: #e8ebff;
  --rozie-dialog-backdrop-bg: rgba(0, 0, 0, 0.7);
  --rozie-dialog-padding: 2rem;
}

Only cosmetic values flow through tokens; the structural behaviour (top-layer rendering, the ::backdrop, UA centering, the focus trap) comes from the native <dialog> and is not consumer-overridable.

The complete token table and the design-system bridges live on the dedicated theming page.

Accessibility ​

  • showModal() renders the dialog in the browser's top layer with role="dialog" and aria-modal="true" applied by the UA, a native focus trap (Tab cycles within the dialog), and focus restoration to the previously-focused element on close — none of which the component has to implement. The component adds the one case the platform misses: a Dialog unmounted while open (removed by an r-if / <Show> with open still true) releases its scroll lock and returns focus to the element focused before it opened.
  • Provide an accessible name with either ariaLabelledby (point at a visible heading — preferred) or ariaLabel (when there is no visible title).
  • Escape fires the native cancel event; the component preventDefault()s it and drives the close through the open model so the binding never desyncs. Set disableEscapeClose to opt out (e.g. a required confirmation).
  • The scrim is the native ::backdrop pseudo-element. A click on the <dialog> element itself (outside the .rozie-dialog-panel) is treated as a backdrop click; set disableBackdropClose to require an explicit action.
  • A prefers-reduced-motion: no-preference enter/leave transition (via @starting-style + allow-discrete) is applied where supported and is a no-op elsewhere — the dialog simply appears.

Browser support ​

The native <dialog> element and showModal() are supported in all current evergreen browsers (Chrome/Edge 37+, Firefox 98+, Safari 15.4+). The @starting-style / allow-discrete enter animation degrades gracefully (the dialog appears without the transition) on engines that don't support it. For pre-2022 browsers, load a <dialog> polyfill before the component.

Pre-1.0 — APIs may change between minor versions.