Source: https://monarchui.dev/components/dialog/

Components · Overlays

# Dialog

Modal with focus trap, Escape and focus return.

@rookcom/monarch-react

Component

## Confirmation

## Usage examples

### Form with a busy save

## Purpose

A modal window for a focused task or decision that must be finished or dismissed before returning to the page.

### Use when

- A short form that should not leave the page.
- A focused decision with custom content.

### Avoid when

- A yes/no confirmation: use ConfirmDialog, which adds the alertdialog role, pending and error states.
- Detail views or longer forms beside the page: use Sheet.
- Phone-first flows that should rise from the bottom: use ResponsiveDialog.
- Non-blocking supplementary content: use Popover.
- Long or multi-step work: use a page.

## Design principles

- Every dialog has a DialogTitle (visually hidden if necessary) so it has an accessible name.
- Header, body and footer are separate parts so long content scrolls while actions stay visible.
- Dismissal is predictable: Escape and the close button always work unless an action is running (`busy`); outside clicks can be turned off for forms with typed values.
- One Escape press closes one layer: a control whose own popup is open keeps the press (`escapeGuard`).

## API reference

```tsx
import { Dialog, DialogBody, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@rookcom/monarch-react';
```

| Export | Kind | Rendering | Root class |
| --- | --- | --- | --- |
| `Dialog` | component | client | `.mn-overlay` |
| `DialogBody` | component | client | `.mn-overlay` |
| `DialogClose` | component | client | `.mn-overlay` |
| `DialogContent` | component | client | `.mn-overlay` |
| `DialogDescription` | component | client | `.mn-overlay` |
| `DialogFooter` | component | client | `.mn-overlay` |
| `DialogHeader` | component | client | `.mn-overlay` |
| `DialogTitle` | component | client | `.mn-overlay` |
| `DialogTrigger` | component | client | `.mn-overlay` |

`Dialog`, `DialogTrigger` and `DialogClose` are the Radix Dialog Root, Trigger and Close.

`DialogContent` renders in a portal with the overlay and an optional built-in close button.

`footer` is shorthand for a trailing `<DialogFooter>`; the parts still work on their own.

Generated from the TypeScript declarations of `packages/react/src/components/overlays/dialog`.

## Properties

### `Dialog`

Modal dialog. Focus is trapped inside, Escape closes it, and focus returns to the trigger. Every dialog needs a DialogTitle (visually hidden if necessary) so it has an accessible name.

| Prop | Type | Description |
| --- | --- | --- |
| `children` | `React.ReactNode` |  |

### `DialogBody`

| Prop | Type | Description |
| --- | --- | --- |
| `children` | `ReactNode` |  |
| `className` | `string` |  |
| `id` | `string` |  |

### `DialogClose`

| Prop | Type | Description |
| --- | --- | --- |
| `asChild` | `boolean` |  |
| `children` | `ReactNode` |  |
| `className` | `string` |  |
| `disabled` | `boolean` |  |
| `id` | `string` |  |

### `DialogContent`

| Prop | Type | Description |
| --- | --- | --- |
| `asChild` | `boolean` |  |
| `busy` | `boolean` | An action is in progress: Escape, outside clicks and the built-in close button no longer close the dialog, and the content is `aria-busy`. Disable your own Cancel button too. |
| `children` | `ReactNode` |  |
| `className` | `string` |  |
| `closeLabel` | `string \| null` | Accessible label of the built-in close button. Pass null to hide the button. |
| `closeOnOutsideClick` | `boolean` | Whether a click outside the dialog closes it. Default `true`; turn it off for forms with typed values so a stray click does not lose them. Escape and the close button still work. |
| `escapeGuard` | `boolean` | Ignore Escape while focus is on a control whose own popup is open (a combobox or menu button with `aria-expanded="true"`), so that press only closes the popup. Default `true`. |
| `footer` | `ReactNode` | Actions rendered in a DialogFooter after the children. The footer stays visible while DialogBody scrolls. |
| `fullScreenOnPhone` | `boolean` | Below 640px wide, fill the whole screen instead of floating with a margin. |
| `id` | `string` |  |
| `size` | `"sm" \| "md" \| "lg" \| "xl"` | Width preset: 24rem, 32rem (default), 44rem or 60rem, capped at the viewport minus 2rem. |

### `DialogDescription`

| Prop | Type | Description |
| --- | --- | --- |
| `asChild` | `boolean` |  |
| `children` | `ReactNode` |  |
| `className` | `string` |  |
| `id` | `string` |  |

### `DialogFooter`

| Prop | Type | Description |
| --- | --- | --- |
| `children` | `ReactNode` |  |
| `className` | `string` |  |
| `id` | `string` |  |

### `DialogHeader`

| Prop | Type | Description |
| --- | --- | --- |
| `children` | `ReactNode` |  |
| `className` | `string` |  |
| `id` | `string` |  |

### `DialogTitle`

| Prop | Type | Description |
| --- | --- | --- |
| `asChild` | `boolean` |  |
| `children` | `ReactNode` |  |
| `className` | `string` |  |
| `id` | `string` |  |

### `DialogTrigger`

| Prop | Type | Description |
| --- | --- | --- |
| `asChild` | `boolean` |  |
| `children` | `ReactNode` |  |
| `className` | `string` |  |
| `disabled` | `boolean` |  |
| `id` | `string` |  |

## Variants

| Prop | Values | Description |
| --- | --- | --- |
| `size (DialogContent)` | `'sm'``'md'``'lg'``'xl'` | Width 24rem, 32rem (default), 44rem or 60rem, never wider than the viewport minus 2rem. Exposed as `data-size`. |
| `fullScreenOnPhone (DialogContent)` | `'false'``'true'` | Below 640px the dialog fills the screen with safe-area padding. Exposed as `data-fullscreen-phone`. |

## States

| State | Exposed as | Behaviour |
| --- | --- | --- |
| Open / closed | `data-state` | From Radix; content enters with the `mn-dialog-in` animation. |
| Without close button | – | `closeLabel={null}` removes the built-in close button. |
| Busy | `data-busy, aria-busy` | `busy` blocks Escape, outside clicks and the built-in close button while an action runs. |

## Interaction behaviour

- Opening moves focus into the dialog; Tab and Shift+Tab stay inside it.
- Escape, the built-in close button or any DialogClose closes it, and focus returns to the trigger.
- A click on the overlay closes it, unless `closeOnOutsideClick={false}`.
- While `busy`, nothing closes it; disable your own Cancel button as well.
- Escape is ignored while focus is on a combobox or menu button whose popup is open (`escapeGuard`, on by default); Radix popups inside the dialog are already stacked layers.
- Controlled with `open` + `onOpenChange`, or uncontrolled.

## Keyboard support

| Keys | Action |
| --- | --- |
| Enter / Space | On the trigger, opens the dialog. |
| Tab / Shift+Tab | Cycles focus within the dialog. |
| Escape | Closes the dialog and returns focus to the trigger. |

## Accessibility

Role

dialog

Accessible name

DialogTitle; DialogDescription becomes the accessible description.

- The built-in close button is named by `closeLabel` (default "Close").
- Content outside the dialog is hidden from assistive technology while open (Radix).
- While `busy`, the content has `aria-busy="true"` and the close button is disabled.
- For confirmations of destructive actions use ConfirmDialog, which sets `role="alertdialog"`.

## Responsive behaviour

- Width is `min(size, 100vw - 2rem)` and height at most `100dvh - 2rem`, so the dialog fits small screens.
- With `fullScreenOnPhone`, below 640px it fills the screen and pads for notches and home bars (`env(safe-area-inset-*)`).
- The body scrolls; header and footer stay put.

## Theme support

- Raised surface, border and shadow tokens; light, dark and more-contrast themes apply automatically.
- The entrance animation uses duration tokens, which are 0ms under `prefers-reduced-motion`.

## Do and don't

### Do

- Always include a DialogTitle.
- Put the confirming action last in the footer.
- Set `busy` while saving so a stray Escape cannot interrupt it.
- Turn off outside clicks for forms with typed values.

### Don't

- Do not open a dialog from inside another dialog.
- Do not hide the only way to close it.
- Do not leave `busy` on after an error; let the person retry or cancel.

## Related components

- [ConfirmDialog](https://monarchui.dev/components/confirm-dialog/): Ready-made confirmation built on Dialog.
- [Sheet](https://monarchui.dev/components/sheet/): Side or bottom panel for longer tasks.
- [ResponsiveDialog](https://monarchui.dev/components/responsive-dialog/): This dialog on wide screens, a bottom sheet on phones.
- [Popover](https://monarchui.dev/components/popover/): Non-modal floating content.

## Dependencies

| Kind | Depends on |
| --- | --- |
| npm packages | `@radix-ui/react-dialog``react` |
| Monarch components | None |

Dismissal rules are shared with Sheet through `src/internal/dismiss.ts`.

## Testing coverage

- Opens, traps focus, closes on Escape and restores focus to the trigger.
- Has an accessible name and description; built-in close button is present.
- `closeLabel={null}` removes the close button.
- No axe violations in the open dialog (jsdom).
- `footer` renders a DialogFooter after the children.
- `busy` blocks Escape, outside clicks and the close button, and sets aria-busy.
- `closeOnOutsideClick={false}` keeps it open on an overlay click; Escape still closes.
- Uncontrolled dialogs close on an overlay click by default.
- Escape guard: Escape on an expanded combobox does not close it; `escapeGuard={false}` lets it through.
- A consumer `onEscapeKeyDown` still runs and its preventDefault is honoured.
- `size` and `fullScreenOnPhone` are exposed as data attributes.
- Renders on the server (closed).

| Check | Covered |
| --- | --- |
| Axe in jsdom | Yes |
| Server rendering | Yes |
| Browser suites | Visual baselines: gallery/dialog and overlays/dialog-light (open); Cross-browser axe: /components/dialog |

Tests: `packages/react/src/components/overlays/dialog`

## All example code

### Confirmation

```tsx
import { Button, Dialog, DialogBody, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@rookcom/monarch-react';

<Dialog>
  <DialogTrigger asChild>
    <Button variant="danger">Delete project</Button>
  </DialogTrigger>
  <DialogContent size="sm">
    <DialogHeader>
      <DialogTitle>Delete project?</DialogTitle>
      <DialogDescription>
        This removes all tasks and sprints. It cannot be undone.
      </DialogDescription>
    </DialogHeader>
    <DialogBody>Type the project name to confirm.</DialogBody>
    <DialogFooter>
      <DialogClose asChild>
        <Button>Cancel</Button>
      </DialogClose>
      <Button variant="danger">Delete</Button>
    </DialogFooter>
  </DialogContent>
</Dialog>
```

### Form with a busy save

```tsx
import { useState } from 'react';
import { Button, Dialog, DialogBody, DialogClose, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger, Field, FieldLabel, Input } from '@rookcom/monarch-react';

function RenameDialog() {
  const [open, setOpen] = useState(false);
  const [saving, setSaving] = useState(false);
  const save = () => {
    setSaving(true);
    window.setTimeout(() => {
      setSaving(false);
      setOpen(false);
    }, 1200);
  };
  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogTrigger asChild>
        <Button>Rename board</Button>
      </DialogTrigger>
      <DialogContent
        busy={saving}
        closeOnOutsideClick={false}
        fullScreenOnPhone
        footer={
          <>
            <DialogClose asChild>
              <Button disabled={saving}>Cancel</Button>
            </DialogClose>
            <Button variant="primary" loading={saving} onClick={save}>
              Save
            </Button>
          </>
        }
      >
        <DialogHeader>
          <DialogTitle>Rename board</DialogTitle>
          <DialogDescription>Everyone with access sees the new name.</DialogDescription>
        </DialogHeader>
        <DialogBody>
          <Field>
            <FieldLabel>Board name</FieldLabel>
            <Input defaultValue="Q3 launch" />
          </Field>
        </DialogBody>
      </DialogContent>
    </Dialog>
  );
}

<RenameDialog />
```
