Theme
Design your own
On this page (7)
Component Skill — components/dialog/component-skill.md

Native basis

<dialog> element + showModal(). The browser provides:

Web Platform APIs

<dialog>HTMLDialogElement.showModal()::backdrop@starting-style

Classes

.dialog.dialog-content.dialog-header.dialog-title.dialog-description.dialog-body.dialog-footer

Sizes (data-size)

smmax-width: 24remmdmax-width: 28rem (default)lgmax-width: 32remxlmax-width: 40remfullmax-width: calc(100vw - 2rem)

Data attributes

• data-dialog-trigger

• data-dialog-close

Wiring conventions

• data-dialog-trigger="[id]" on any element → opens that dialog

• data-dialog-close on any element inside → closes the dialog

• Click on backdrop → closes (click lands on <dialog> itself)

• Place <dialog> elements as direct children of <body>

Notes

• Animation uses CSS-only enter via @starting-style and exit via transition + allow-discrete.

• While the dialog is modal, html:has(dialog.dialog:modal) sets overflow: hidden + scrollbar-gutter: stable - the page behind cannot scroll and its position is preserved for when the dialog closes (no JS scroll-lock).

• The selector is dialog.dialog (element + class) to avoid styling native <dialog> elements used elsewhere.

• For forms inside dialogs, use the dialog-body wrapper for the form content.

§Form Dialog

Dialog with form inputs. Click 'Edit Profile' to open.

§Confirmation

Destructive action confirmation. Click 'Confirm Delete' to open.

§Density

Set data-density on the {''} root to scale the content padding. A whitespace policy, not a zoom: only padding scales (ratio 0.75 / 1 / 1.25), typography and the width ladder (data-size) stay identical. comfortable matches the unsized default.

§States

Named states via the shared State API, driven per instance through the bound api:

  • default - closed (the authored state)
  • open - shown modally via showModal()

The demo dialog carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/dialog-{state}.png.

Machine contract - verified against dialog.schema.json by bun run verify (§13–§18):

StateTypeValuesDefaultDescription
openbooleantrue, falsefalseModal visibility - observed via the native open property; true requires showModal() (or the showModal action), false via close().

§API

Generated from dialog.ts and the shared State API - the descriptions are their JSDoc, the types are checked by the compiler.

States

type DialogState = 'default' | 'open' - setState(name, config) takes the config of the state it names.

StateDescription
default
Closed.

No config.

open
Open as a modal (showModal()) - Escape and a backdrop click close it.

No config.

Every element

MemberDescription
el.api.setState<S extends DialogState>(name: S, config?: DialogStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
nameSa declared state (an unknown name throws)
config?DialogStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (or await settled())

el.api.getState(): { name: DialogState; config: DialogStateConfigs[DialogState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.

Returns { name: DialogState; config: DialogStateConfigs[DialogState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

el.api.render(state?: { name: DialogState; config: DialogStateConfigs[DialogState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state?{ name: DialogState; config: DialogStateConfigs[DialogState]; model?: ElementModel }a state as getState() returns it (default: the current one)

Returns string - the element's outer HTML in that state

el.api.settled(): Promise<void>
Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting).

Returns Promise<void> - resolves when nothing is pending

el.store: Store<{ name: DialogState; config: DialogStateConfigs[DialogState] }>A defuss-store store of the element's state - subscribe to follow every change (also the user's), set it to drive the component.

Registry

MemberDescription
df$.shadcn.dialogApi.setState<S extends DialogState>(el: HTMLElement, name: S, config?: DialogStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSa declared state (an unknown name throws)
config?DialogStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (await it, or el.api.settled())

df$.shadcn.dialogApi.getState(el: HTMLElement): { name: DialogState; config: DialogStateConfigs[DialogState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.
ArgumentTypeDescription
elHTMLElementthe component's element

Returns { name: DialogState; config: DialogStateConfigs[DialogState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

df$.shadcn.dialogApi.render(state: { name: DialogState; config: DialogStateConfigs[DialogState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state{ name: DialogState; config: DialogStateConfigs[DialogState]; model?: ElementModel }a state as getState() returns it (with its model)

Returns string - the element's outer HTML in that state

df$.shadcn.dialogApi.store(el: HTMLElement): Store<{ name: DialogState; config: DialogStateConfigs[DialogState] }>
The element's store (bindComponent made it).
ArgumentTypeDescription
elHTMLElementthe component's element

Returns Store<{ name: DialogState; config: DialogStateConfigs[DialogState] }> - a defuss-store store of { name, config } - subscribe to follow every change, set it to drive the component

df$.shadcn.dialogApi.commit<S extends DialogState>(el: HTMLElement, name: S, config?: DialogStateConfigs[S]): void
Record a state the element reached on its own (no DOM work) - for a component's own handlers.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSthe state it is in
config?DialogStateConfigs[S]its config
df$.shadcn.dialogStates: DialogState[]The declared states, 'default' first: default, open.

§CSS view file

/* -- Dialog component ------------------------------------------ */
@layer components {
  dialog.dialog {
    border: none;
    border-radius: var(--radius-xl);
    background-color: var(--popover);
    color: var(--popover-foreground);
    padding: 0;
    margin: auto;
    position: fixed;
    inset: 0;
    max-width: 28rem;
    width: calc(100vw - 2rem);
    max-height: calc(100vh - 2rem);
    box-shadow: 0 25px 80px oklch(0 0 0 / 0.25), 0 0 0 1px var(--border);
    opacity: 0;
    transform: translateY(-0.5rem) scale(0.98);
    transition: opacity 200ms ease, transform 200ms ease, display 200ms allow-discrete;
    &[open] {
      opacity: 1;
      transform: translateY(0) scale(1);
    }
    /* -- Sizes ------------------------------------------------ */
    &[data-size="sm"]   { max-width: 24rem; }
    &[data-size="md"]   { max-width: 28rem; }
    &[data-size="lg"]   { max-width: 32rem; }
    &[data-size="xl"]   { max-width: 40rem; }
    &[data-size="full"] { max-width: calc(100vw - 2rem); }
    /* -- Backdrop --------------------------------------------- */
    &::backdrop {
      background: oklch(0 0 0 / 0);
      backdrop-filter: blur(0px);
      transition: all 200ms ease, display 200ms allow-discrete;
    }
    &[open]::backdrop {
      background: oklch(0 0 0 / 0.45);
      backdrop-filter: blur(3px);
    }
  }
  @starting-style {
    dialog.dialog[open] {
      opacity: 0;
      transform: translateY(-0.5rem) scale(0.98);
    }
    dialog.dialog[open]::backdrop {
      background: oklch(0 0 0 / 0);
      backdrop-filter: blur(0px);
    }
  }
  /* -- Scroll lock ------------------------------------------- */
  /* While the dialog is modal the page behind must stay put - closing
     returns you to exactly the scroll offset you had. `:modal` matches only
     while opened via showModal(); overflow:hidden freezes the viewport
     without losing the scroll position (no JS position:fixed hack), and
     scrollbar-gutter:stable keeps the scrollbar's space reserved so its
     removal can't shift the layout underneath. */
  html:has(dialog.dialog:modal) {
    overflow: hidden;
    scrollbar-gutter: stable;
  }
  /* -- Density ------------------------------------------------
     data-density on the .dialog root scales the content padding
     (0.75 / 1 / 1.25 of the 1.5rem default); comfortable matches the
     unsized default. Width stays governed by data-size - two axes. */
  .dialog:where([data-density="compact"]) .dialog-content      { padding: 1rem; }
  .dialog:where([data-density="comfortable"]) .dialog-content  { padding: 1.5rem; }
  .dialog:where([data-density="spacious"]) .dialog-content     { padding: 2rem; }
  /* -- Content sections ------------------------------------- */
  .dialog-content { padding: 1.5rem; }
  .dialog-header  { margin-bottom: 1rem; }
  .dialog-title   { font-size: 1.0625rem; font-weight: 600; margin: 0 0 0.375rem; letter-spacing: -0.01em; }
  .dialog-description { font-size: 0.875rem; color: var(--muted-foreground); margin: 0; line-height: 1.6; }
  .dialog-body    { margin-top: 1rem; }
  .dialog-footer  { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 1.5rem; }
}
/* Accessibility: reduced motion removes open/close transitions (REQUIRED
   for all components - AGENTS.md "Accessibility CSS"). */
@media (prefers-reduced-motion: reduce) {
  @layer components {
    dialog.dialog,
    dialog.dialog::backdrop { transition: none; }
  }
}

§JavaScript view file

Wire triggers via data-dialog-trigger, close buttons via data-dialog-close, and backdrop click. Focus returns to trigger on close.

// -- Dialog ---------------------------------------------------
// Wires [data-dialog-trigger] buttons to <dialog> elements, plus the
// named-state API so agents/tests can drive states by name (AGENTS.md "State API").
// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —
// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.
import { defussGlobals, defussQuery, componentState, bindComponent } from '../../../shared/state-api.js';
const df$ = defussGlobals();
const dfDollar = defussQuery();
const dialogStates = ['default', 'open'];
// VERIFIED: (verify's API docs gate) the states below are exactly the declared ones, each
// described, and every config field typed, described and named in the code.
/** setState() configs per state - the dialog's states take none. */
export interface DialogStateConfigs {
  /** Closed. */
  default: {};
  /** Open as a modal (showModal()) - Escape and a backdrop click close it. */
  open: {};
}
/**
 * The markup of a state: an open dialog carries `open`. render() applies it
 * to a detached copy; on the live dialog showModal()/close() (the native
 * protocol: top layer, focus, inert background) produce exactly this
 * attribute - the e2e render round trip proves they agree.
 */
function applyMarkup(dialog, stateName) {
  dfDollar(dialog).attr('open', stateName === 'open' ? '' : null);
}
/**
 * UI side of setState: 'default' closes, 'open' opens modally. Native
 * <dialog> can't animate to a declared state it's not in, so this is a
 * direct showModal()/close() dispatch; unknown names are rejected upstream.
 */
function triggerStateChange(dialog, stateName, _config) {
  switch (stateName) {
    case 'default':
      if (dialog.open) dialog.close();
      break;
    case 'open':
      if (!dialog.open) dialog.showModal();
      break;
  }
}
/** The state a dialog shows: its `open` - read back after every change, and the state it starts in. */
const shown = (dialog: HTMLDialogElement) => (dialog.open ? 'open' : 'default');
/** Registry-level API; pass the dialog element explicitly. Unknown names throw. */
export const dialogApi = componentState<HTMLDialogElement>({
  component: 'dialog',
  states: dialogStates,
  apply: (dialog, state) => triggerStateChange(dialog, state.name, state.config),
  // the state is the dialog's `open`, as the schema observes it: a trigger, a
  // native commandfor button or authored markup open it without setState
  read: (dialog, state) => ({ name: shown(dialog), config: state.config }),
  markup: (el, state) => applyMarkup(el, state.name),
});
df$.dialogApi = dialogApi;
df$.dialogStates = dialogStates;
function init() {
  dfDollar('[data-dialog-trigger]:not([data-init])').toArray().forEach((trigger) => {
    trigger.dataset.init = '';
    const dialog = dfDollar<HTMLDialogElement>('#' + CSS.escape(trigger.dataset.dialogTrigger)).get(0);
    if (!dialog) return;
    trigger.addEventListener('click', () => {
      dialog._trigger = trigger;
      dialog.showModal();
    });
  });
  /* .command excluded: the command component owns its dialogs (own backdrop
     close, filtering, focus). Without this, dialog.js - which loads first —
     claims them via data-init and command.js's init silently skips them. */
  dfDollar<HTMLDialogElement>('dialog:not(.alert-dialog):not(.sheet):not(.command):not(.window):not(.cookie-consent-dialog):not([data-init])').toArray().forEach((dialog) => {
    dfDollar(dialog).data('init', '');
    // el.store + el.api (AGENTS.md "State through stores")
    bindComponent(dialog, dialogApi, { name: shown(dialog), config: {} });
    dialog.addEventListener('click', (e) => {
      if (e.target === dialog) dialog.close();
    });
    dfDollar(dialog).find('[data-dialog-close]').toArray().forEach((btn) => {
      btn.addEventListener('click', () => { dialog.close(); });
    });
    dialog.addEventListener('close', () => {
      // `close` fires AFTER the exit transition (display allow-discrete), so a
      // fast re-open can beat it - a stale event must not yank focus out of an
      // open dialog. The state itself follows `open` through read().
      if (dialog.open) return;
      if (dialog._trigger) dialog._trigger.focus();
    });
  });
}
init();
new MutationObserver(init).observe(document, { childList: true, subtree: true });

Comments, ideas or improvements? Edit this page's source on GitHub