Skip to content
Domphy

Dialog

Apply the dialog patch to a <dialog> element. It drives open/close via the native showModal()/close() API, fades with a 200 ms opacity transition, and closes when the user clicks the backdrop or presses Escape (via the animated state path, not an abrupt browser close).

The patch handles accessibility automatically: it sets aria-modal="true", traps Tab focus within the dialog while open (cycling between first and last focusable elements), restores focus to the previously focused element when closed, and locks page scroll while open.

The dialog surface uses a high elevation() box-shadow (no outline — shadow-only, the modern modal look) and a density-scaled border-radius.

Props

PropTypeDefaultDescription
openValueOrState<boolean>falseControls visibility. Set to true to open, false to close.
colorThemeColor"neutral"Theme color tone for the dialog surface.

Example

import { toState } from "@domphy/core";
import { button, dialog } from "@domphy/ui";

const open = toState(false);

const App = {
  div: [
    {
      button: "Open",
      $: [button()],
      onClick: () => open.set(true),
    },
    {
      dialog: [
        { h3: "Confirm action" },
        { p: "Are you sure you want to continue?" },
        {
          button: "Close",
          $: [button({ color: "primary" })],
          onClick: () => open.set(false),
        },
      ],
      $: [dialog({ open })],
    },
  ],
};
Customization

Must see the source of patch at the bottom of each patch page to understand the structure then code it still code as html native element.

There are four levels of customization, in increasing order of effort:

  1. Patch props. Each patch exposes a small, stable set of props—typically fewer than five. Lowest friction.
  2. Context attributes. Use dataTone, dataSize, and dataDensity on a container to shift tone, size, or density for an entire subtree without touching individual elements.
  3. Inline override. Native-wins merge strategy: any property set directly on the element overrides the patch value.
  4. Create a variant. Clone a similar patch and edit it. Use this only when you need a reusable custom version.
Formulas

Unit - U = fontSize / 4 - convert final values with themeSpacing(n).

Size - n = intrinsic text lines, w = wrapping level, d = density factor:

height        = (n * 6 + 2 * d * w) * U
paddingBlock  = d * w * U
paddingInline = ceil(3 / w) * d * w * U
radius        = d * w * U

Base density d = 1.5:

Uw=0w=1w=2w=3
height (n = 1)691215
paddingBlock01.534.5
paddingInline34.564.5
radius01.534.5

Tone - K = N / 2 where N is the palette length. For N = 18, K = 9.

RoleShiftn=0
Backgroundparent +/- n0
Textbg + K6
Borderbg + K/23
Hoverbg + 2K/34
Selected / Focusabove +/- K/32-4

State shift range: K/3 <= delta <= 2K/3.

import {
  type BehaviorInstance,
  behavior,
  type ElementNode,
  type PartialElement,
  type State,
  toState,
  type ValueOrState,
} from "@domphy/core";
import {
  type ThemeColor,
  themeColor,
  themeDensity,
  themeSize,
  themeSpacing,
} from "@domphy/theme";
import { elevation } from "../utils/elevation.js";
import { lockScroll, unlockScroll } from "../utils/scrollLock.js";

const FOCUSABLE =
  'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), details, [tabindex]:not([tabindex="-1"])';

type DialogProps = {
  state: State<boolean>;
};

type DialogInstance = BehaviorInstance<DialogProps> & {
  // Close via the instance's CURRENT state — routing through the behavior
  // guarantees the state being set is the one the open/close subscription
  // listens to, even when a re-rendered generation's closure would otherwise
  // set its own fresh (unsubscribed) default state.
  requestClose: () => void;
  onTransitionEnd: (e: Event) => void;
};

// All close-finalization state (closing flag, previousFocus, scrollLocked,
// the fallback timer) lives in ONE behavior instance per dialog node. It used
// to be factory-scope variables captured by _onMount — but _onMount runs once
// for the FIRST generation only, while onTransitionEnd (live-rebound every
// patch) belongs to the LATEST generation: gen-1's update() set gen-1's
// `closing = true` and the live gen-N handler read gen-N's `closing` (false)
// and early-returned, so every close fell back to the 350ms timer and focus
// restore scrolled through stale closures. behavior() attaches once and
// routes every later generation's props into the same instance (floating.ts
// pattern).
function attachDialog(
  node: ElementNode,
  initialProps: DialogProps,
): DialogInstance {
  let { state } = initialProps;
  let previousFocus: HTMLElement | null = null;
  let closing = false;
  let scrollLocked = false;
  let closeTimer: ReturnType<typeof setTimeout> | null = null;

  const dlg = node.domElement as HTMLDialogElement;
  dlg.setAttribute("aria-modal", "true");

  const finalizeClose = () => {
    closing = false;
    // Guard for environments with HTMLDialogElement but no close()
    // implementation (e.g. jsdom in tests).
    if (typeof dlg.close === "function") dlg.close();
    // visibility/pointer-events (not just opacity) must reflect the closed
    // state: opacity alone leaves a closed dialog's content fully reachable
    // by Tab and exposed to the accessibility tree (opacity, unlike
    // visibility, never removes an element from either) — and a consumer
    // that sets its own `style: { display: ... }` on the dialog (a common
    // pattern for centering content) overrides the UA stylesheet's
    // `dialog:not([open]) { display: none }`, so `dlg.close()` alone doesn't
    // reliably hide it either. Set INLINE so it always wins regardless of
    // what the consumer's own style object declares.
    dlg.style.visibility = "hidden";
    dlg.style.pointerEvents = "none";
    if (scrollLocked) {
      unlockScroll();
      scrollLocked = false;
    }
    previousFocus?.focus();
    previousFocus = null;
  };

  const trapFocus = (e: KeyboardEvent) => {
    if (e.key !== "Tab") return;
    const focusables = Array.from(
      dlg.querySelectorAll<HTMLElement>(FOCUSABLE),
    ).filter(
      (el) => !el.closest("[aria-hidden='true']") && el.offsetParent !== null,
    );
    if (!focusables.length) {
      e.preventDefault();
      return;
    }
    const first = focusables[0];
    const last = focusables[focusables.length - 1];
    if (e.shiftKey) {
      if (document.activeElement === first || document.activeElement === dlg) {
        e.preventDefault();
        last.focus();
      }
    } else {
      if (document.activeElement === last) {
        e.preventDefault();
        first.focus();
      }
    }
  };

  const onCancel = (e: Event) => {
    e.preventDefault();
    state.set(false);
  };
  dlg.addEventListener("cancel", onCancel);

  const update = (val: boolean) => {
    if (val) {
      // Cancel any in-flight close: a pending fallback timer or a true
      // `closing` flag left over from the previous close (e.g. reopening
      // within the 350ms window, or the mount-time close of an
      // initially-closed dialog) would otherwise finalize-close the
      // just-opened dialog on the open fade's own transitionend.
      closing = false;
      if (closeTimer) {
        clearTimeout(closeTimer);
        closeTimer = null;
      }
      previousFocus = document.activeElement as HTMLElement;
      dlg.style.visibility = "visible";
      dlg.style.pointerEvents = "auto";
      // Guard for environments with HTMLDialogElement but no showModal()
      // implementation (e.g. jsdom in tests). Also guard against re-entering
      // on an already-open dialog — showModal() throws InvalidStateError in
      // real browsers when the dialog is already open.
      if (typeof dlg.showModal === "function" && !dlg.open) dlg.showModal();
      if (!scrollLocked) {
        lockScroll();
        scrollLocked = true;
      }
      dlg.addEventListener("keydown", trapFocus);
      requestAnimationFrame(() => {
        dlg.style.opacity = "1";
        const focusable = dlg.querySelector<HTMLElement>(FOCUSABLE);
        focusable?.focus();
      });
    } else {
      closing = true;
      dlg.style.opacity = "0";
      dlg.removeEventListener("keydown", trapFocus);
      // Fallback: if transitionend never fires (reduced-motion, display:none),
      // unblock close after the transition duration + buffer.
      closeTimer = setTimeout(() => {
        closeTimer = null;
        if (!closing) return;
        finalizeClose();
      }, 350);
    }
  };
  update(state.get());
  let release = state.addListener(update);

  return {
    requestClose: () => state.set(false),
    onTransitionEnd: (e: Event) => {
      if (!closing) return;
      // Guard against bubbled transitionend from nested content (e.g. an
      // accordion/details transition inside the dialog) prematurely
      // triggering close-finalization.
      if (e.target !== dlg) return;
      if ((e as TransitionEvent).propertyName !== "opacity") return;
      finalizeClose();
    },
    update(props) {
      // Re-subscribe when a later generation brings a genuinely different
      // state object (e.g. the default `toState(false)` allocated per
      // factory call); a caller-owned state arrives as the SAME object and
      // keeps its existing subscription untouched.
      if (props.state !== state) {
        release();
        state = props.state;
        release = state.addListener(update);
      }
    },
    destroy() {
      if (closeTimer) {
        clearTimeout(closeTimer);
        closeTimer = null;
      }
      release();
      dlg.removeEventListener("cancel", onCancel);
      if (scrollLocked) {
        unlockScroll();
        scrollLocked = false;
      }
      dlg.removeEventListener("keydown", trapFocus);
      previousFocus?.focus();
      previousFocus = null;
    },
  };
}

/**
 * Modal dialog patch driven by an `open` State. Calls `showModal()`/`close()`,
 * fades via opacity, locks page scroll while open, traps Tab focus within the
 * dialog, restores focus to the previously focused element on close, sets
 * `aria-modal`, and closes on outside (backdrop) click. Apply to a `<dialog>`.
 *
 * @hostTag dialog
 * @param props.color - Theme color tone for the dialog surface. Defaults to "neutral".
 * @param props.open - Open state (`ValueOrState<boolean>`); set it to true/false to show/hide. Defaults to false.
 * @example { dialog: [...], $: [dialog({ open })] }
 */
function dialog(
  props: { color?: ThemeColor; open?: ValueOrState<boolean> } = {},
): PartialElement {
  const { color = "neutral", open = false } = props;
  const state = toState(open);

  return {
    _onInsert: (node) => {
      if (node.tagName !== "dialog") {
        console.warn(`"dialog" primitive patch must use dialog tag`);
      }
    },
    ...behavior<DialogProps>("dialog", attachDialog, { state }),
    onClick: (e: MouseEvent, node) => {
      if (e.target !== node.domElement) return;
      const r = node.domElement!.getBoundingClientRect();
      const inside =
        e.clientX >= r.left &&
        e.clientX <= r.right &&
        e.clientY >= r.top &&
        e.clientY <= r.bottom;
      if (!inside) node.getBehavior<DialogInstance>("dialog")?.requestClose();
    },
    onTransitionEnd: (e, node) =>
      node.getBehavior<DialogInstance>("dialog")?.onTransitionEnd(e),
    style: {
      opacity: "0",
      // Matches finalizeClose's inline defaults — a dialog that mounts
      // already-closed (the common case: `open` defaults to false) must
      // start out of the tab order/accessibility tree from first paint, not
      // just after its first open->close cycle runs finalizeClose.
      visibility: "hidden",
      pointerEvents: "none",
      transition: "opacity 200ms ease",
      fontSize: (listener) => themeSize(listener, "inherit"),
      color: (listener) => themeColor(listener, "shift-10", color),
      backgroundColor: (listener) => themeColor(listener, "inherit", color),
      border: "none",
      borderRadius: (listener) => themeSpacing(themeDensity(listener) * 2),
      padding: (listener) => themeSpacing(themeDensity(listener) * 3),
      boxShadow: elevation("high"),
      "&::backdrop": {
        backgroundColor: (listener) =>
          themeColor(listener, "shift-2", "neutral"),
        opacity: 0.75,
      },
    },
  };
}

export { dialog };