Skip to content

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

SlotPurpose
dialogRoot element. Controller is bound here.
dialog-triggerButton that toggles the dialog. Optional.
dialog-portalWrapper moved to <body> on open. Optional but recommended.
dialog-overlayBackdrop element behind the content. Required.
dialog-contentThe dialog panel. Required.
dialog-titleAccessible title, linked via aria-labelledby.
dialog-descriptionAccessible description, linked via aria-describedby.
dialog-closeAny 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:

AttributeValuesDescription
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

EventDetailDescription
dialog:change{ open: boolean }Fires when the dialog opens or closes.

dialog:change

interface DialogChangeDetail {
  open: boolean;
}
FieldDescription
openWhether the dialog is now open.

Inbound

EventDetailDescription
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)

OptionTypeDefaultDescription
defaultOpenbooleanfalseInitial open state.
onOpenChange(open: boolean) => voidCalled when open state changes.
closeOnClickOutsidebooleantrueClose when clicking the overlay.
closeOnEscapebooleantrueClose when pressing Escape.
lockScrollbooleantrueLock body scroll while the dialog is open.
alertDialogbooleanfalseUse role="alertdialog" for confirmation flows.
onPortalMounted(container: HTMLElement) => voidAfter 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

MemberTypeDescription
isOpenboolean (getter)Current open state.
open()() => voidOpen the dialog.
close()() => voidClose the dialog.
toggle()() => voidToggle the dialog open state.
destroy()() => voidRemove 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 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.