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.
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
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.
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.
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.
<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
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.
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.
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
autofocusreceives 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
lockScrollistrue(default), the<body>scroll is locked while the dialog is open and restored on close. - Portal: When a
dialog-portalslot exists, its contents are moved todocument.bodyon first open, avoiding z-index and overflow clipping issues.