Skip to content

Dialog

A modal dialog that blocks page interaction, traps focus, and dismisses on Escape. Built on the native <dialog> element for correct top-layer stacking and browser-managed accessibility semantics — no extra JS focus trapping or z-index juggling required.

Features

  • 🔒 Native <dialog> — correct top-layer stacking, built-in backdrop, browser focus trapping
  • ⌨️ Escape to close — handled by the browser natively
  • 🎯 Controlled open state — toggle with the open attribute or property
  • 🔘 Dismissible — optional close (×) button in the header
  • 🛡️ Persistent mode — prevent accidental close via backdrop click
  • 🧩 Flexible slotsheader, default body, and footer
  • 📐 5 Sizes: sm, md, lg, xl, full
  • 🎨 3 Variants: default, plain, bordered
  • Accessible: role="dialog", aria-modal="true", aria-label from label prop, labelled close button

Source Code

View Source Code
ts
import type { OverlayCloseDetail, OverlayCloseReason, OverlayOpenDetail } from '@vielzeug/craftit/controls';

import { define, on, html, prop, ref, signal, watch, onMounted } from '@vielzeug/craftit';
import { createOverlayControl } from '@vielzeug/craftit/controls';

import type { PaddingSize, RoundedSize } from '../../types';

import '../../content/icon/icon';
import { coarsePointerMixin, elevationMixin, roundedVariantMixin } from '../../styles';
import { lockBackground, unlockBackground, useOverlay } from '../../utils';
import componentStyles from './dialog.css?inline';

type DialogSize = 'sm' | 'md' | 'lg' | 'xl' | 'full';
type DialogBackdrop = 'opaque' | 'blur' | 'transparent';
type DialogElevation = 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';

/** Dialog component properties */

export type BitDialogEvents = {
  close: OverlayCloseDetail;
  open: OverlayOpenDetail;
};

export type BitDialogProps = {
  /** Backdrop style — 'blur' (default): dark overlay + blur; 'opaque': dark overlay only; 'transparent': no overlay */
  backdrop?: DialogBackdrop;
  /** Show a close (×) button in the header */
  dismissible?: boolean;
  /** Panel shadow elevation — defaults to 'xl' */
  elevation?: DialogElevation;
  /**
   * CSS selector for the element inside the dialog that should receive focus when the dialog opens.
   * Defaults to the first focusable element (browser default).
   * @example 'input[name="email"]' | '#confirm-btn'
   */
  'initial-focus'?: string;
  /** Dialog title shown in the header (used as aria-label when no header slot) */
  label?: string;
  /** Controls the open state of the dialog */
  open?: boolean;
  /** Internal padding size */
  padding?: PaddingSize;
  /** When true, clicking the backdrop does not close the dialog */
  persistent?: boolean;
  /**
   * When true (default), the focus returns to the element that triggered the dialog after it closes.
   * Set to false if you want to manage focus manually.
   */
  'return-focus'?: boolean;
  /** Border radius */
  rounded?: RoundedSize | '';
  /** Dialog size */
  size?: DialogSize;
};

/**
 * A modal dialog that traps focus, blocks page interaction, and dismisses on
 * `Escape`. Built on the native `<dialog>` element for correct top-layer stacking
 * and browser-managed accessibility.
 *
 * @element bit-dialog
 *
 * @attr {boolean} open - Open/close the dialog
 * @attr {string} label - Dialog title (also used as aria-label)
 * @attr {string} size - Size: 'sm' | 'md' | 'lg' | 'xl' | 'full'
 * @attr {boolean} dismissible - Show a close (×) button in the header
 * @attr {boolean} persistent - Prevent backdrop-click from closing
 * @attr {string} rounded - Border radius size
 * @attr {string} backdrop - Backdrop style: 'opaque' (default) | 'blur' | 'transparent'
 * @attr {string} elevation - Panel shadow: 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
 * @attr {string} padding - Padding: 'none' | 'sm' | 'md' | 'lg' | 'xl'
 *
 * @fires open - Fired when the dialog opens with detail: { reason }
 * @fires close - Fired when the dialog closes with detail: { reason }
 *
 * @slot - Dialog body content
 * @slot header - Custom header content (replaces the default title + close layout)
 * @slot footer - Action buttons or additional content at the bottom
 *
 * @cssprop --dialog-bg - Panel background color
 * @cssprop --dialog-border-color - Panel border color
 * @cssprop --dialog-radius - Panel border radius
 * @cssprop --dialog-shadow - Panel drop shadow
 * @cssprop --dialog-padding - Padding for header, body, and footer sections
 * @cssprop --dialog-gap - Gap between footer action buttons
 * @cssprop --dialog-backdrop - Backdrop overlay color
 * @cssprop --dialog-max-width - Maximum panel width (overridden by size prop)
 *
 * @part dialog - Dialog root container.
 * @part overlay - Overlay backdrop element.
 * @part panel - Panel container.
 * @part header - Header container.
 * @part title - Title text element.
 * @part close - Close action control.
 * @part body - Body content container.
 * @part footer - Footer container.
 * @example
 * ```html
 * <bit-dialog label="Confirm action" dismissible>
 *   <p>Are you sure you want to delete this item?</p>
 *   <div slot="footer">
 *     <bit-button variant="ghost" id="cancel">Cancel</bit-button>
 *     <bit-button color="error" id="confirm">Delete</bit-button>
 *   </div>
 * </bit-dialog>
 *
 * <script type="module">
 *   import '@vielzeug/buildit/dialog';
 *   const dialog = document.querySelector('bit-dialog');
 *   document.querySelector('#open-btn').addEventListener('click', () => {
 *     dialog.setAttribute('open', '');
 *   });
 *   document.querySelector('#cancel').addEventListener('click', () => {
 *     dialog.removeAttribute('open');
 *   });
 * </script>
 * ```
 */

export const DIALOG_TAG = define<BitDialogProps, BitDialogEvents>('bit-dialog', {
  props: {
    backdrop: undefined,
    dismissible: false,
    elevation: undefined,
    'initial-focus': undefined,
    label: '',
    open: false,
    padding: undefined,
    persistent: false,
    'return-focus': true,
    rounded: undefined,
    size: prop.oneOf(['sm', 'md', 'lg', 'xl', 'full'] as const, 'md'),
  },
  setup(props, { emit, host, slots }) {
    const dialogRef = ref<HTMLDialogElement>();
    const isOpen = signal(false);
    const hasHeader = () => slots.has('header').value || !!props.label.value || props.dismissible.value;
    const hasFooter = () => slots.has('footer').value;
    let closeReason: OverlayCloseReason = 'programmatic';

    const dispatchCloseRequest = (reason: Exclude<OverlayCloseReason, 'programmatic'>): boolean => {
      return host.el.dispatchEvent(
        new CustomEvent('close-request', {
          bubbles: true,
          cancelable: true,
          composed: true,
          detail: { reason },
        }),
      );
    };

    // ────────────────────────────────────────────────────────────────
    // Overlay State Management
    // ────────────────────────────────────────────────────────────────

    const { applyInitialFocus, captureReturnFocus, closeWithAnimation, restoreFocus } = useOverlay(
      host.el,
      dialogRef,
      () => dialogRef.value?.querySelector<HTMLElement>('.panel'),
      props,
    );

    const overlay = createOverlayControl({
      getBoundaryElement: () => host.el,
      getPanelElement: () => dialogRef.value?.querySelector<HTMLElement>('.panel') ?? null,
      isOpen: () => isOpen.value,
      onOpen: (reason) => emit('open', { reason }),
      setOpen: (next, { reason }) => {
        const dialog = dialogRef.value;

        if (!dialog) return;

        if (next) {
          if (dialog.open) return;

          captureReturnFocus();
          dialog.showModal();
          applyInitialFocus();
          lockBackground(host.el);
          isOpen.value = true;

          return;
        }

        if (dialog.open) {
          closeReason = reason as OverlayCloseReason;
          closeWithAnimation();

          return;
        }

        isOpen.value = false;
      },
    });

    // ────────────────────────────────────────────────────────────────
    // Lifecycle: Setup Native Dialog Integration
    // ────────────────────────────────────────────────────────────────

    const handleDismiss = () => {
      const dialog = dialogRef.value;

      if (!dialog) return;

      const closeAllowed = dispatchCloseRequest('trigger');

      if (!closeAllowed) return;

      closeReason = 'trigger';
      overlay.close({ reason: 'trigger', restoreFocus: false });
    };

    onMounted(() => {
      const dialog = dialogRef.value;

      if (!dialog) return;

      // Sync prop changes → native dialog
      watch(
        props.open,
        (open) => {
          if (open) {
            overlay.open({ reason: 'programmatic' });

            return;
          }

          overlay.close({ reason: 'programmatic', restoreFocus: false });
        },
        { immediate: true },
      );

      // ────────────────────────────────────────────────────────────
      // Event Handlers: Close, Escape, Backdrop Click
      // ────────────────────────────────────────────────────────────

      const handleNativeClose = () => {
        unlockBackground();
        host.el.removeAttribute('open');
        isOpen.value = false;
        restoreFocus();
        emit('close', { reason: closeReason });
        closeReason = 'programmatic';
      };

      const requestClose = (reason: Exclude<OverlayCloseReason, 'programmatic'>) => {
        const closeAllowed = dispatchCloseRequest(reason);

        if (!closeAllowed) return;

        closeReason = reason;
        overlay.close({ reason, restoreFocus: false });
      };

      const handleKeydown = (e: KeyboardEvent) => {
        if (e.key === 'Escape' && !props.persistent.value) {
          e.preventDefault();
          requestClose('escape');
        }
      };

      const handleBackdropClick = (e: MouseEvent) => {
        if (props.persistent.value) return;

        // Click target is the <dialog> element itself (not the panel)
        if (e.target === dialog) {
          requestClose('outside-click');
        }
      };

      on(dialog, 'close', handleNativeClose);
      on(dialog, 'click', handleBackdropClick);
      on(dialog, 'keydown', handleKeydown);

      return () => {
        // Ensure the native dialog is closed on unmount to release top-layer
        if (dialog.open) dialog.close();

        unlockBackground();
      };
    });

    return () => html`
      <dialog ref=${dialogRef} class="dialog" part="dialog" aria-label="${props.label}" aria-modal="true">
        <div class="overlay" part="overlay" aria-hidden="true"></div>
        <div class="panel" part="panel" :data-size="${props.size}">
          <div class="header" part="header" ?hidden=${() => !hasHeader()}>
            <slot name="header">
              <span class="title" part="title">${props.label}</span>
            </slot>
            <button
              class="close"
              part="close"
              type="button"
              aria-label="Close dialog"
              ?hidden=${() => !props.dismissible.value}
              @click=${handleDismiss}>
              <bit-icon name="x" size="16" stroke-width="2.5" aria-hidden="true"></bit-icon>
            </button>
          </div>
          <div class="body" part="body">
            <slot></slot>
          </div>
          <div class="footer" part="footer" ?hidden=${() => !hasFooter()}>
            <slot name="footer"></slot>
          </div>
        </div>
      </dialog>
    `;
  },
  styles: [elevationMixin, roundedVariantMixin, coarsePointerMixin, componentStyles],
});

Basic Usage

Use the open attribute to show the dialog and remove it (or set it to false) to close it.

html
<bit-button id="open-btn">Open dialog</bit-button>

<bit-dialog label="Confirm action" dismissible id="dialog">
  <p>Are you sure you want to delete this item? This action cannot be undone.</p>
  <div slot="footer">
    <bit-button variant="ghost" id="cancel-btn">Cancel</bit-button>
    <bit-button color="error" id="confirm-btn">Delete</bit-button>
  </div>
</bit-dialog>

<script type="module">
  import '@vielzeug/buildit/dialog';
  import '@vielzeug/buildit/button';

  const dialog = document.getElementById('dialog');

  document.getElementById('open-btn').addEventListener('click', () => {
    dialog.setAttribute('open', '');
  });
  document.getElementById('cancel-btn').addEventListener('click', () => {
    dialog.removeAttribute('open');
  });
  document.getElementById('confirm-btn').addEventListener('click', () => {
    dialog.removeAttribute('open');
    // handle confirm...
  });
</script>

Sizes

PreviewCode
RTL

Dismissible

Add dismissible to show a close (×) button in the top-right corner of the header.

PreviewCode
RTL

Backdrop

Control the backdrop appearance with the backdrop attribute.

PreviewCode
RTL

Elevation

Control the panel drop shadow with the elevation attribute. Defaults to xl.

PreviewCode
RTL

Padding

Control the internal padding of the header, body, and footer with the padding attribute. Defaults to lg (24 px).

PreviewCode
RTL

Custom Header

Use the header slot to replace the default title + close-button layout entirely.

PreviewCode
RTL

Persistent (No Backdrop Close)

Set persistent to prevent the dialog from closing when the user clicks outside the panel. Useful for forms where accidental dismissal would lose data.

PreviewCode
RTL

Listening to Events

javascript
const dialog = document.querySelector('bit-dialog');

dialog.addEventListener('open', (e) => {
  console.log('Dialog opened because:', e.detail.reason);
});

dialog.addEventListener('close', (e) => {
  console.log('Dialog closed because:', e.detail.reason);
  // Re-enable the trigger button, reset form state, etc.
});

dialog.addEventListener('close-request', (e) => {
  if (e.detail.reason === 'outside-click') {
    // Optional: block accidental outside dismiss in a critical flow.
    e.preventDefault();
  }
});

API Reference

Attributes

AttributeTypeDefaultDescription
openbooleanfalseControls whether the dialog is visible
labelstring''Dialog title shown in the header; used as aria-label
size'sm' | 'md' | 'lg' | 'xl' | 'full''md'Panel width preset
dismissiblebooleanfalseShow a close (×) button in the header
persistentbooleanfalsePrevent backdrop-click from closing the dialog
rounded'none' | 'sm' | 'md' | 'lg' | ... | 'full'Override the panel border radius
backdrop'blur' | 'opaque' | 'transparent''opaque'Backdrop style — blur overlay, opaque (default) overlay, or none
elevation'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl''md'Panel drop shadow depth
padding'none' | 'sm' | 'md' | 'lg' | 'xl''md'Padding for header, body, and footer

Events

EventDetailDescription
open{ reason: 'programmatic' }Fired once when the dialog transitions to open
close{ reason: 'programmatic' | 'trigger' | 'escape' | 'outside-click' }Fired when the dialog closes
close-request{ reason: 'trigger' | 'escape' | 'outside-click' }Fired before close and can be prevented

Slots

SlotDescription
(default)Dialog body content
headerCustom header content — replaces the default title + close-button layout
footerAction buttons or supplemental content pinned to the bottom of the panel

CSS Custom Properties

PropertyDescriptionDefault
--dialog-bgPanel background colorvar(--color-canvas)
--dialog-border-colorPanel border colorvar(--color-contrast-300)
--dialog-radiusPanel border radiusvar(--rounded-lg)
--dialog-shadowPanel drop shadowvar(--shadow-xl)
--dialog-paddingPadding for header, body, and footer sectionsvar(--size-6)
--dialog-gapGap between footer action buttonsvar(--size-4)
--dialog-backdropBackdrop overlay colourrgba(0, 0, 0, 0.5)
--dialog-max-widthMaximum panel width (overridden by size)32rem

Accessibility

The dialog component follows the WAI-ARIA Dialog (Modal) Pattern and is built on the native <dialog> element for WCAG 2.1 Level AA compliance.

bit-dialog

Keyboard Navigation

KeyAction
TabMove focus to the next focusable element inside the dialog (wraps around)
Shift + TabMove focus to the previous focusable element (wraps around)
EscapeClose the dialog (handled natively by the browser)

Screen Readers

  • The inner <dialog> element carries role="dialog" implicitly — no extra ARIA role is needed.
  • aria-modal="true" signals to assistive technologies that content outside the dialog is inert while it is open.
  • When label is set, it becomes the aria-label of the dialog, giving screen readers a concise title to announce on open.
  • When dismissible is set, the close button has a descriptive aria-label="Close dialog".

Focus Management

  • On open, the browser moves focus into the dialog panel automatically — no manual focus() call required.
  • Focus is trapped inside the dialog while it is open; pressing Tab cycles only through interactive elements within the panel.
  • On close, focus returns to the element that triggered the dialog — standard browser behavior for the native <dialog>.

Label every dialog

Always set a label (or provide a custom header slot). Screen readers announce the dialog title immediately when focus moves into the panel, so a missing label creates a disorienting "unlabelled dialog" announcement.

Persistent dialogs

When using persistent, always include an accessible way to dismiss — either dismissible or a clearly labelled cancel button in the footer. A dialog with no dismissal mechanism traps keyboard users indefinitely.

Best Practices

Do:

  • Provide a descriptive label — it becomes the accessible dialog title.
  • Include a clearly labelled cancel/close action in the footer slot so keyboard and pointer users can dismiss without relying solely on Escape.
  • Use persistent only when data would be lost otherwise (e.g. multi-step forms). Always still provide a way to intentionally dismiss (use dismissible or a footer cancel button).
  • Keep dialog content focused — if a dialog requires scrolling, it's usually a sign the content should live on its own page.

Don't:

  • Nest dialogs; stack them in a queue instead.
  • Use dialogs for non-blocking notifications — use bit-alert or a toast component instead.
  • Open dialogs without user intent (e.g. on page load) — this is disorienting for screen reader users.