Skip to content

Components / UI

Dialog

A native modal that scales in over a blurred backdrop and scales out again, closes on Escape, the close button or a backdrop click, and traps focus, all handled by the browser. Zero JavaScript: the trigger uses the invoker command attribute; the exit is transition-behavior: allow-discrete.

Live demo

dialog

Native, animated, zero script.

Escape, backdrop click and focus are the browser's. With morph, the box you clicked is the box that opened: a view transition, back into the button on close.

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

0 B

With morph: 522 B raw of its own on the runtime and the morph helper, measured on its own fixture. The default above ships none of it.

CSS 6.8 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Baseline · newly available

Chrome 135 · Firefox 144 · Safari 26.2; needs command, starting-style, allow-discrete, dialog. Elsewhere: without invoker commands the trigger needs polyfill (an inline handler of 403 B raw); without @starting-style and allow-discrete the modal opens and closes without motion; without closedby (Safari) a backdrop click does nothing, and Escape and the close button still close it.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import Dialog from '@moonarc/core/Dialog';
---
<Dialog id="terms" label="Read the terms">
  <h2>Terms</h2>
  <p>Short.</p>
</Dialog>

<Dialog id="custom" polyfill>
  <button slot="trigger" type="button" command="show-modal" commandfor="custom">Any trigger</button>
  <p>Contents.</p>
</Dialog>

<!-- grows out of its trigger on a view transition -->
<Dialog id="card" morph label="Open the card">
  <p>The box you clicked is the box that opened.</p>
</Dialog>
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the shared runtime, the morph helper and the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/dialog.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win. If you paste it, also copy internal/DialogMorph.astro, runtime.ts and morph.ts.

Dialog.astro
---
/**
 * Dialog — a native modal that opens and closes with motion, zero JS.
 *
 * The trigger is a button with the invoker `command="show-modal"`; closing
 * is a form with method="dialog", which every browser has. Motion is
 * @starting-style in, transition-behavior: allow-discrete out, so display
 * and the top layer wait for the exit to finish. `polyfill` adds an inline
 * click handler (403 B raw) for browsers that predate invoker commands. `morph`
 * renders one internal component with a measured script (lib/morph): the
 * box grows out of its trigger on a same-document view transition and
 * returns to it on close; the default stays at 0 B.
 */
import type { HTMLAttributes } from 'astro/types';
import DialogMorph from './internal/DialogMorph.astro';

interface Props extends HTMLAttributes<'dialog'> {
  /** Element id; the trigger points at it. */
  id: string;
  /** Trigger label; or use the `trigger` slot. */
  label?: string;
  /** Close on backdrop click or Escape (closedby="any"). */
  dismiss?: boolean;
  /** Show the close button. */
  close?: boolean;
  /** Accessible name of the close button. */
  closeLabel?: string;
  /** Add the inline click handler for browsers without invoker commands. */
  polyfill?: boolean;
  /** Grow out of the trigger and back into it on a same-document view transition (adds the measured morph script; without view transitions or under reduced motion it opens and closes as usual). */
  morph?: boolean;
}

const { id, label = 'Open', dismiss = true, close = true, closeLabel = 'Close', polyfill = false, morph = false, class: className, ...rest } = Astro.props;
const triggerAttrs = { command: 'show-modal', commandfor: id };
const dialogAttrs = dismiss ? { closedby: 'any' as const } : {};
---

{Astro.slots.has('trigger') ? <slot name="trigger" /> : <button type="button" class="ma-dialog__trigger" {...triggerAttrs}>{label}</button>}
<dialog id={id} class:list={['ma-dialog', className]} data-morph={morph ? '' : undefined} {...dialogAttrs} {...rest}>
  <div class="ma-dialog__panel">
    <slot />
    {close && (
      <form method="dialog" class="ma-dialog__close">
        <button type="submit" aria-label={closeLabel}>
          <svg aria-hidden="true" viewBox="0 0 16 16" width="16" height="16"><path d="M4 4l8 8M12 4l-8 8" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" /></svg>
        </button>
      </form>
    )}
  </div>
</dialog>
{morph && <DialogMorph for={id} />}
{polyfill && <script is:inline>window.__maCmd||(window.__maCmd=1,document.addEventListener('click',function(e){if('command'in HTMLButtonElement.prototype||e.defaultPrevented)return;var b=e.target.closest('[commandfor]');if(!b)return;var d=document.getElementById(b.getAttribute('commandfor'));if(!d)return;var c=b.getAttribute('command');c==='show-modal'?d.showModal():c==='close'?d.close():c==='toggle-popover'?d.togglePopover():0}))</script>}

<style is:global>
  @layer components {
    :where(.ma-dialog) {
      inset: 0;
      margin: auto;
      max-width: min(32rem, calc(100vw - 2rem));
      border: 1px solid var(--ma-edge);
      border-radius: 1rem;
      padding: 0;
      background: var(--ma-panel);
      color: inherit;
      box-shadow: 0 24px 64px -24px rgb(0 0 0 / 0.4);
      opacity: 0;
      scale: 0.96;
      translate: 0 var(--ma-travel-hover, 4px);
      transition:
        opacity var(--ma-duration-fast) var(--ma-ease-out),
        scale var(--ma-duration-fast) var(--ma-ease-out),
        translate var(--ma-duration-fast) var(--ma-ease-out),
        display var(--ma-duration-fast) allow-discrete,
        overlay var(--ma-duration-fast) allow-discrete;
    }
    :where(.ma-dialog[open]) {
      opacity: 1;
      scale: 1;
      translate: 0 0;
      transition-duration: var(--ma-duration);
      transition-timing-function: var(--ma-ease);
    }
    @starting-style {
      :where(.ma-dialog[open]) {
        opacity: 0;
        scale: 0.96;
        translate: 0 var(--ma-travel-enter, 24px);
      }
    }
    :where(.ma-dialog)::backdrop {
      background: var(--ma-scrim);
      backdrop-filter: blur(6px);
      opacity: 0;
      transition:
        opacity var(--ma-duration-fast) var(--ma-ease-out),
        display var(--ma-duration-fast) allow-discrete,
        overlay var(--ma-duration-fast) allow-discrete;
    }
    :where(.ma-dialog[open])::backdrop {
      opacity: 1;
      transition-duration: var(--ma-duration);
    }
    @starting-style {
      :where(.ma-dialog[open])::backdrop {
        opacity: 0;
      }
    }
    :where(.ma-dialog__panel) {
      position: relative;
      padding: 1.5rem;
    }
    /* the close button's corner stays clear: a first line that runs the width of the panel ends before it */
    :where(.ma-dialog__panel:has(> .ma-dialog__close)) {
      padding-inline-end: 3.25rem;
    }
    :where(.ma-dialog__close) {
      position: absolute;
      inset-block-start: 0.75rem;
      inset-inline-end: 0.75rem;
    }
    /* a plain button with no page reset under it: no UA border, background, padding or font */
    :where(.ma-dialog__close button) {
      display: grid;
      place-items: center;
      width: 2rem;
      height: 2rem;
      padding: 0;
      border: 0;
      border-radius: 999px;
      background: none;
      color: inherit;
      font: inherit;
      cursor: pointer;
      opacity: 0.6;
      transition: opacity var(--ma-duration-fast) var(--ma-ease-out), background-color var(--ma-duration-fast) var(--ma-ease-out);
    }
    :where(.ma-dialog__close button:hover) {
      opacity: 1;
      background: var(--ma-edge);
    }
    :where(.ma-dialog__trigger) {
      cursor: pointer;
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-dialog),
      :where(.ma-dialog[open]),
      :where(.ma-dialog)::backdrop {
        scale: 1;
        translate: 0 0;
        transition-duration: var(--ma-duration-fast);
        transition-timing-function: linear;
      }
    }
  }
</style>

Props

PropTypeDefaultDescription
idstringnoneDialog id; the trigger points at it.
labelstring'Open'Trigger label. Use the trigger slot for custom markup (give it command="show-modal" commandfor={id}).
dismissbooleantrueClose on backdrop click, via closedby="any" (Chrome 134, Firefox 141; not in Safari yet).
closebooleantrueRender the close button (a form with method="dialog"). The panel keeps its inline-end corner clear for it.
closeLabelstring'Close'Accessible name of the close button.
polyfillbooleanfalseInline a click handler (403 B raw) for browsers without invoker commands (Safari < 26.2, Firefox < 144). It is document-level: one per page covers every Dialog, Drawer and Popover trigger.
morphbooleanfalseGrow out of the trigger and return to it on a same-document view transition. Adds the measured morph script (see the Dialog.morph budget); without view transitions or under reduced motion the dialog opens and closes as usual. Lightbox and VideoDialog build on it.

Reduced motion

Opens and closes with a 150 ms crossfade; no scale, no travel. With morph, the view transition is skipped and the dialog opens as usual.

With ClientRouter

Native; nothing to rebind. A dialog open during navigation is closed with the page. The morph script binds through the shared runtime and sets its view-transition-name only for the duration of a transition, so the router never sees it.

Why it is built this way

Replaces: Dialog (Radix / shadcn) · Modal (Aceternity) · AnimatedModal (Aceternity). See the migration table.

Zero JavaScript?

For this one, yes. The browser animates the height.

Keyboard?

Native: Tab, Enter, Space.

One at a time?

details name=, also native.

Native <details> items whose height animates open and closed, with an optional one-at-a-time rule the browser enforces.

newly · Chrome 131 · Firefox 143 · Safari 18.4click

drawer

Drag it down.

Follows the finger; on release, distance or velocity decides. Grab it mid-flight and it obeys.

A bottom sheet that slides up over a scrim and can be dragged closed: it follows the finger, and on release springs back or leaves depending on distance and velocity, interruptible mid-flight.

newly · Chrome 135 · Firefox 144 · Safari 26.2clickpointer

Raw bytes of JavaScript, from a production build, measured again for every change that can move them.

A popover that opens beside its trigger, flips when there is no room, closes on outside click or Escape, and animates in and out.

newly · Chrome 125 · Firefox 147 · Safari 26click

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown