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

Native basis

<dialog> element used as a modal that requires user response. Unlike a standard dialog, it has no backdrop-close and no close button - the user must choose an action.

Web Platform APIs

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

Classes

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

Data attributes

• data-alert-dialog-trigger

• data-alert-dialog-close

Wiring conventions

• data-alert-dialog-trigger on any element - opens the component

• data-alert-dialog-close on any element inside - closes the component

Accessibility

• Uses role="alertdialog" instead of role="dialog" - signals interruption

• aria-labelledby and aria-describedby link to title and description

• No backdrop click dismiss - user must make an explicit choice

• Escape key is disabled - user must use the action buttons

• Focus is trapped inside the dialog via native showModal()

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

§Basic

Confirm/cancel pattern with title and description.

§Confirmation

Non-destructive confirmation with standard action button.

§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 stays 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; only close buttons or the API close it - Escape is blocked)
  • open - shown modally via showModal()

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

Machine contract - verified against alert-dialog.schema.json by bun run verify:

StateTypeValuesDefaultDescription
openbooleantrue, falsefalseShown via showModal(); hidden via close() (native open property).

§API

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

States

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

StateDescription
default
Closed.

No config.

open
Open as a modal (showModal()) - the background is inert until it is answered.

No config.

Every element

MemberDescription
el.api.setState<S extends AlertDialogState>(name: S, config?: AlertDialogStateConfigs[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?AlertDialogStateConfigs[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: AlertDialogState; config: AlertDialogStateConfigs[AlertDialogState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.

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

el.api.render(state?: { name: AlertDialogState; config: AlertDialogStateConfigs[AlertDialogState]; 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: AlertDialogState; config: AlertDialogStateConfigs[AlertDialogState]; 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: AlertDialogState; config: AlertDialogStateConfigs[AlertDialogState] }>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.alertDialogApi.setState<S extends AlertDialogState>(el: HTMLElement, name: S, config?: AlertDialogStateConfigs[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?AlertDialogStateConfigs[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.alertDialogApi.getState(el: HTMLElement): { name: AlertDialogState; config: AlertDialogStateConfigs[AlertDialogState]; 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: AlertDialogState; config: AlertDialogStateConfigs[AlertDialogState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

df$.shadcn.alertDialogApi.render(state: { name: AlertDialogState; config: AlertDialogStateConfigs[AlertDialogState]; 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: AlertDialogState; config: AlertDialogStateConfigs[AlertDialogState]; model?: ElementModel }a state as getState() returns it (with its model)

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

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

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

df$.shadcn.alertDialogApi.commit<S extends AlertDialogState>(el: HTMLElement, name: S, config?: AlertDialogStateConfigs[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?AlertDialogStateConfigs[S]its config
df$.shadcn.alertDialogStates: AlertDialogState[]The declared states, 'default' first: default, open.

§CSS view file

Entry/exit animation

@layer components {
  dialog.alert-dialog {
    border: none;
    border-radius: var(--radius-xl);
    background: var(--background);
    color: var(--foreground);
    padding: 0;
    max-width: 28rem;
    width: calc(100% - 2rem);
    box-shadow: var(--shadow-lg);
    margin: auto;
    position: fixed;
    inset: 0;
    /* Entry/exit animation */
    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);
    }
    &::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);
    }
    /* Block Escape key - user must choose an action */
    &::backdrop {
      pointer-events: auto;
    }
  }
  @starting-style {
    dialog.alert-dialog[open] {
      opacity: 0;
      transform: translateY(-0.5rem) scale(0.98);
    }
    dialog.alert-dialog[open]::backdrop {
      background: oklch(0 0 0 / 0);
      backdrop-filter: blur(0px);
    }
  }
  /* -- Scroll lock ------------------------------------------- */
  /* Page behind stays put while the alert is modal (see dialog.css):
     `:modal` + overflow:hidden freezes the viewport at its current offset. */
  html:has(dialog.alert-dialog:modal) {
    overflow: hidden;
    scrollbar-gutter: stable;
  }
  .alert-dialog-content {
    padding: 1.5rem;
  }
  /* -- Density ------------------------------------------------
     data-density on the .alert-dialog root scales the content padding
     (0.75 / 1 / 1.25 of the 1.5rem default); comfortable matches the
     unsized default. */
  .alert-dialog:where([data-density="compact"]) .alert-dialog-content     { padding: 1rem; }
  .alert-dialog:where([data-density="comfortable"]) .alert-dialog-content { padding: 1.5rem; }
  .alert-dialog:where([data-density="spacious"]) .alert-dialog-content    { padding: 2rem; }
  .alert-dialog-header {
    margin-bottom: 1.25rem;
  }
  .alert-dialog-title {
    margin: 0;
    font-size: 1.125rem;
    font-weight: 600;
    letter-spacing: -0.01em;
    line-height: 1.3;
  }
  .alert-dialog-description {
    margin: 0.5rem 0 0;
    font-size: 0.875rem;
    color: var(--muted-foreground);
    line-height: 1.5;
  }
  .alert-dialog-footer {
    display: flex;
    justify-content: flex-end;
    gap: 0.5rem;
  }
}
/* Accessibility: suppress motion for users who request it (REQUIRED for all
   components - AGENTS.md "Accessibility CSS"). Near-zero duration instead of
   `none` keeps transitionend/animationend (and discrete display flips)
   firing so JS state machines that await them keep working. */
@media (prefers-reduced-motion: reduce) {
  @layer components {
    .alert-dialog,
    .alert-dialog *,
    .alert-dialog::before,
    .alert-dialog::after,
    .alert-dialog *::before,
    .alert-dialog *::after,
    .alert-dialog::backdrop,
    .alert-dialog-content,
    .alert-dialog-content *,
    .alert-dialog-content::before,
    .alert-dialog-content::after,
    .alert-dialog-content *::before,
    .alert-dialog-content *::after,
    .alert-dialog-content::backdrop,
    .alert-dialog-header,
    .alert-dialog-header *,
    .alert-dialog-header::before,
    .alert-dialog-header::after,
    .alert-dialog-header *::before,
    .alert-dialog-header *::after,
    .alert-dialog-header::backdrop,
    .alert-dialog-title,
    .alert-dialog-title *,
    .alert-dialog-title::before,
    .alert-dialog-title::after,
    .alert-dialog-title *::before,
    .alert-dialog-title *::after,
    .alert-dialog-title::backdrop,
    .alert-dialog-description,
    .alert-dialog-description *,
    .alert-dialog-description::before,
    .alert-dialog-description::after,
    .alert-dialog-description *::before,
    .alert-dialog-description *::after,
    .alert-dialog-description::backdrop,
    .alert-dialog-footer,
    .alert-dialog-footer *,
    .alert-dialog-footer::before,
    .alert-dialog-footer::after,
    .alert-dialog-footer *::before,
    .alert-dialog-footer *::after,
    .alert-dialog-footer::backdrop {
      transition-duration: 0.01ms !important;
      animation-duration: 0.01ms !important;
      animation-iteration-count: 1 !important;
    }
  }
}

§JavaScript view file

Alert Dialog

// -- Alert Dialog ----------------------------------------------
// Wires [data-alert-dialog-trigger] buttons to <dialog class="alert-dialog">.
// Unlike regular dialogs: no backdrop-close, Escape key blocked. Named-state
// API per AGENTS.md "State API" (camelCase alert-dialog → alertDialogApi).
// 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 alertDialogStates = ['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 alert dialog's states take none. */
export interface AlertDialogStateConfigs {
  /** Closed. */
  default: {};
  /** Open as a modal (showModal()) - the background is inert until it is answered. */
  open: {};
}
/**
 * The markup of a state: 'open' carries `open`. render() applies it to a
 * detached copy; on the live element showModal()/close() (the native
 * protocol: top layer, focus, inert background) produce exactly this
 * attribute - the e2e render round trip proves they agree.
 */
function applyMarkup(el, stateName) {
  dfDollar(el).attr('open', stateName === 'open' ? '' : null);
}
/**
 * UI side of setState: 'default' closes, 'open' opens modally. Escape and
 * backdrop dismissal stay blocked by the listeners below; closing is
 * programmatic only (close buttons / api).
 */
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 alertDialogApi = componentState<HTMLDialogElement>({
  component: 'alert-dialog',
  states: alertDialogStates,
  apply: (dialog, state) => triggerStateChange(dialog, state.name, state.config),
  // the state is the dialog's `open`, as the schema observes it: a trigger or
  // a native commandfor button opens it without setState
  read: (dialog, state) => ({ name: shown(dialog), config: state.config }),
  markup: (el, state) => applyMarkup(el, state.name),
});
df$.alertDialogApi = alertDialogApi;
df$.alertDialogStates = alertDialogStates;
function init() {
/* Wire triggers */
dfDollar('[data-alert-dialog-trigger]:not([data-init])').toArray().forEach((trigger) => {
  trigger.dataset.init = '';
  const dialog = dfDollar<HTMLDialogElement>('#' + CSS.escape(trigger.dataset.alertDialogTrigger)).get(0);
  if (!dialog) return;
  trigger.addEventListener('click', () => {
    dialog._trigger = trigger;
    dialog.showModal();
  });
});
/* Wire close buttons and block Escape */
dfDollar<HTMLDialogElement>('dialog.alert-dialog:not([data-init])').toArray().forEach((dialog) => {
  dialog.dataset.init = '';
  // el.store + el.api (AGENTS.md "State through stores")
  bindComponent(dialog, alertDialogApi, { name: shown(dialog), config: {} });
  /* Block Escape key */
  dialog.addEventListener('cancel', (e) => {
    e.preventDefault();
  });
  /* Wire close buttons */
  dfDollar(dialog).find('[data-alert-dialog-close]').toArray().forEach((btn) => {
    btn.addEventListener('click', () => {
      dialog.close();
    });
  });
  /* Return focus to trigger */
  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