Skip to content

Dialog

A primitive for building modal dialogs with focus trapping and portal support.

Updated View as Markdown

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

Type to search…

↑↓ navigate↵ selectEsc close