# Dialog (Moonarc)

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.

- Import: `import Dialog from '@moonarc/core/Dialog'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/dialog.json`
- Tier A · category ui · trigger click
- Readout: `<Dialog command="show-modal">`
- Browser support: newly (Chrome 135 · Firefox 144 · Safari 26.2); 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
- Measured cost: 0 B JS (CSS 6.8 kB raw)
- Page: https://moonarc.dev/components/dialog/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `id` | `string` | none | Dialog id; the trigger points at it. |
| `label` | `string` | `'Open'` | Trigger label. Use the trigger slot for custom markup (give it command="show-modal" commandfor={id}). |
| `dismiss` | `boolean` | `true` | Close on backdrop click, via closedby="any" (Chrome 134, Firefox 141; not in Safari yet). |
| `close` | `boolean` | `true` | Render the close button (a form with method="dialog"). The panel keeps its inline-end corner clear for it. |
| `closeLabel` | `string` | `'Close'` | Accessible name of the close button. |
| `polyfill` | `boolean` | `false` | Inline 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. |
| `morph` | `boolean` | `false` | Grow 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. |

## Usage

```astro
<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>
```

## 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.

## Craft

- Enter uses the preset spring at the preset duration; exit is 150 ms ease-out, because exits are always faster than entrances.
- display and overlay transition with allow-discrete so the element leaves the top layer only after the fade completes.
- The close button is a form with method="dialog": that closes a dialog in every browser that has one, no command attribute needed. It sits at the inline end (the left in a right-to-left page), and the panel pads that side so text never runs under it.
- The panel has a theme-aware background and a 24 px shadow; the backdrop blurs 6 px so the page recedes without going black.
- morph is an optional prop that carries a script: it is rendered as an internal component only when set, so the default measures 0 B and the variant is measured separately. The trigger and the dialog share one view-transition-name for one transition each way; the root gets a plain cross-fade on the tokens through html[data-ma-vt], which also keeps a Curtain on the same page from opening its iris.

## Replaces

- Dialog (Radix / shadcn)
- Modal (Aceternity)
- AnimatedModal (Aceternity)

## Source

```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>

```
