---
title: Dialog
description: A primitive for building modal dialogs with focus trapping and portal support.
order: 8
tags: [primitives]
---

# Dialog

A primitive for building modal dialogs. Provides focus trapping, focus restoration, optional scroll locking, Escape/outside-click dismissal, portal support, and modal stacking for nested dialogs.

```ts
import { Dialog } from "@areia/slots";

document.querySelector("#root")!.innerHTML = `
  <div data-slot="dialog">
    <button data-slot="dialog-trigger" class="win95-button">Open dialog</button>
    <div data-slot="dialog-portal">
      <div data-slot="dialog-overlay" class="fixed inset-0 bg-black/40"></div>
      <div data-slot="dialog-content" class="win95-window fixed left-1/2 top-1/2 w-80 -translate-x-1/2 -translate-y-1/2">
        <h2 data-slot="dialog-title" class="win95-titlebar">Dialog title</h2>
        <p data-slot="dialog-description" class="mb-3">
          Supporting text that describes the purpose of the
          dialog.
        </p>
        <button data-slot="dialog-close" class="win95-button">Close</button>
      </div>
    </div>
  </div>
`;

const root = document.querySelector<HTMLElement>(
  '[data-slot="dialog"]',
)!;
if (root) {
  const controller = Dialog.createDialog(root, {
    onOpenChange: (open) =>
      console.log("dialog", open ? "opened" : "closed"),
  });
}
```

## Import

```ts
import { Dialog } from "@areia/slots";
```

## Usage

Call `createDialog` with the root element and optional configuration. The primitive reads slot attributes from the DOM and manages open/close, focus trapping, scroll locking, and dismiss behavior.

```ts
import { Dialog } from "@areia/slots";

const el = document.querySelector<HTMLElement>(
  '[data-slot="dialog"]',
)!;
if (!el) throw new Error("No root element found");

const controller = Dialog.createDialog(el, {
  closeOnClickOutside: true,
  closeOnEscape: true,
  lockScroll: true,
});

// Programmatic control
controller.open();
// controller.close();
// controller.toggle();
// controller.destroy();
```

### Auto-bind

Use the `create` helper to discover and bind every unattached root in a scope.

```ts
import { Dialog } from "@areia/slots";

const controllers = Dialog.create(document);
// controllers.forEach((c) => { ... });
```

## Expected Markup

The primitive expects the following slot structure. The `dialog-overlay` and `dialog-content` slots are required.

```html
<div data-slot="dialog">
  <button data-slot="dialog-trigger">Open dialog</button>

  <div data-slot="dialog-portal">
    <div data-slot="dialog-overlay"></div>

    <div data-slot="dialog-content">
      <h2 data-slot="dialog-title">Dialog title</h2>
      <p data-slot="dialog-description">
        Supporting text that describes the purpose of the
        dialog.
      </p>

      <button data-slot="dialog-close">Close</button>
    </div>
  </div>
</div>
```

When a `dialog-portal` slot is present, its contents are automatically moved to `<body>` on first open to escape stacking context issues. If omitted, overlay and content remain in place.

### Slot Reference

| Slot                 | Purpose                                                      |
| -------------------- | ------------------------------------------------------------ |
| `dialog`             | Root element. Controller is bound here.                      |
| `dialog-trigger`     | Button that toggles the dialog. Optional.                    |
| `dialog-portal`      | Wrapper moved to `<body>` on open. Optional but recommended. |
| `dialog-overlay`     | Backdrop element behind the content. **Required.**           |
| `dialog-content`     | The dialog panel. **Required.**                              |
| `dialog-title`       | Accessible title, linked via `aria-labelledby`.              |
| `dialog-description` | Accessible description, linked via `aria-describedby`.       |
| `dialog-close`       | Any element with this slot closes the dialog on click.       |

### State Attributes

The controller manages these attributes on the `root`, `dialog-portal`, `dialog-overlay`, and `dialog-content` elements:

| Attribute     | Values                | Description                        |
| ------------- | --------------------- | ---------------------------------- |
| `data-state`  | `"open"` / `"closed"` | Current open state.                |
| `data-open`   | (present)             | Present when the dialog is open.   |
| `data-closed` | (present)             | Present when the dialog is closed. |

## Events

All events are dispatched from the root element (`data-slot="dialog"`).

### Outbound

| Event           | Detail              | Description                            |
| --------------- | ------------------- | -------------------------------------- |
| `dialog:change` | `{ open: boolean }` | Fires when the dialog opens or closes. |

#### `dialog:change`

```ts
interface DialogChangeDetail {
  open: boolean;
}
```

| Field  | Description                     |
| ------ | ------------------------------- |
| `open` | Whether the dialog is now open. |

### Inbound

| Event        | Detail              | Description                                     |
| ------------ | ------------------- | ----------------------------------------------- |
| `dialog:set` | `{ open: boolean }` | Set the open state from outside the controller. |

Send `dialog:set` on the root element to open or close the dialog programmatically without a controller reference.

```ts
const root = document.querySelector<HTMLElement>(
  '[data-slot="dialog"]',
)!;
root?.dispatchEvent(
  new CustomEvent("dialog:set", {
    bubbles: true,
    detail: { open: true },
  }),
);
```

## API Reference

### Options

`Dialog.createDialog(root, options)`

| Option                | Type                               | Default | Description                                                                                                                                |
| --------------------- | ---------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `defaultOpen`         | `boolean`                          | `false` | Initial open state.                                                                                                                        |
| `onOpenChange`        | `(open: boolean) => void`          | —       | Called when open state changes.                                                                                                            |
| `closeOnClickOutside` | `boolean`                          | `true`  | Close when clicking the overlay.                                                                                                           |
| `closeOnEscape`       | `boolean`                          | `true`  | Close when pressing Escape.                                                                                                                |
| `lockScroll`          | `boolean`                          | `true`  | Lock body scroll while the dialog is open.                                                                                                 |
| `alertDialog`         | `boolean`                          | `false` | Use `role="alertdialog"` for confirmation flows.                                                                                           |
| `onPortalMounted`     | `(container: HTMLElement) => void` | —       | After `dialog-portal` moves to `document.body` on open. Optional bridge for portaled UI when the app renderer cannot patch teleported DOM. |

Options can also be set via `data-*` attributes on the root element, using kebab-case names (e.g. `data-close-on-escape="false"`). JS options take precedence over data attributes.

### Controller

| Member      | Type               | Description                              |
| ----------- | ------------------ | ---------------------------------------- |
| `isOpen`    | `boolean` (getter) | Current open state.                      |
| `open()`    | `() => void`       | Open the dialog.                         |
| `close()`   | `() => void`       | Close the dialog.                        |
| `toggle()`  | `() => void`       | Toggle the dialog open state.            |
| `destroy()` | `() => void`       | Remove all event listeners and clean up. |

## Controller

The controller is the imperative handle returned by `createDialog`. Use it for programmatic control when you need to open or close the dialog from application logic.

```ts
import { Dialog } from "@areia/slots";

const el = document.querySelector<HTMLElement>(
  '[data-slot="dialog"]',
)!;
if (!el) throw new Error("No root element");

const ctrl = Dialog.createDialog(el);

// Read state
console.log(ctrl.isOpen); // boolean

// Open
ctrl.open();

// Close
ctrl.close();

// Toggle
ctrl.toggle();

// Tear down all listeners and clean up
ctrl.destroy();
```

### Behavior

- **Focus trap**: Tab and Shift+Tab cycle through focusable elements within `dialog-content`. When no focusable elements exist, the content itself receives focus and Tab is prevented.
- **Auto-focus**: On open, the first element with `autofocus` receives focus. Otherwise the first focusable element is focused. Falls back to the content element.
- **Focus restoration**: On close, focus returns to the element that was active before the dialog opened. If that element is no longer in the DOM, focus returns to the trigger.
- **Modal stack**: Nested dialogs are tracked on a global modal stack. Escape key dismisses the topmost layer first. Only one overlay is considered "topmost" at a time.
- **Scroll lock**: When `lockScroll` is `true` (default), the `<body>` scroll is locked while the dialog is open and restored on close.
- **Portal**: When a `dialog-portal` slot exists, its contents are moved to `document.body` on first open, avoiding z-index and overflow clipping issues.
