APG Patterns

Dialog (Modal)

A window overlaid on the primary window, rendering the content underneath inert.

Demo

Basic Dialog

A simple modal dialog with title, description, and close functionality.

Dialog Title

This is a description of the dialog content. It provides additional context for users.

This is the main content of the dialog. You can place any content here, such as text, forms, or other components.

Press Escape or click outside to close.

Without Description

A dialog with only a title and content.

Simple Dialog

This dialog has no description, only a title and content.

Accessibility Features

WAI-ARIA Roles

Role Target Element Description
dialog Dialog container Indicates the element is a dialog window

WAI-ARIA dialog role (opens in new tab)

WAI-ARIA Properties

Attribute Target Values Required Description
aria-modal dialog true Yes Indicates this is a modal dialog
aria-labelledby dialog ID reference to title element Yes References the dialog title
aria-describedby dialog ID reference to description No References optional description text

Focus Management

Event Behavior
Dialog opens Focus moves to first focusable element inside the dialog
Dialog closes Focus returns to the element that triggered the dialog
Focus trap Tab/Shift+Tab cycles through focusable elements within the dialog only
Background Content outside dialog is made inert (not focusable or interactive)

Keyboard Support

Key Action
Tab Move focus to next focusable element within dialog. When focus is on the last element, moves to first.
Shift + Tab Move focus to previous focusable element within dialog. When focus is on the first element, moves to last.
Escape Close the dialog and return focus to trigger element

Additional Notes

  • The dialog title is required for accessibility and should clearly describe the purpose of the dialog
  • Page scrolling is disabled while the dialog is open
  • Clicking the overlay (background) closes the dialog by default
  • The close button has an accessible label for screen readers

Source Code

Dialog.astro
---
/**
 * APG Dialog (Modal) Pattern - Astro Implementation
 *
 * A window overlaid on the primary window, rendering the content underneath inert.
 * Uses native <dialog> element with Web Components for enhanced control.
 *
 * @see https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/
 */

export interface Props {
  /** Dialog title (required for accessibility) */
  title: string;
  /** Optional description text */
  description?: string;
  /** Trigger button text */
  triggerText: string;
  /** Close on overlay click */
  closeOnOverlayClick?: boolean;
  /** Additional CSS class for trigger button */
  triggerClass?: string;
  /** Additional CSS class for dialog */
  class?: string;
}

const {
  title,
  description,
  triggerText,
  closeOnOverlayClick = true,
  triggerClass = "",
  class: className = "",
} = Astro.props;

// Generate unique ID for this instance
const instanceId = `dialog-${Math.random().toString(36).substring(2, 11)}`;
const titleId = `${instanceId}-title`;
const descriptionId = `${instanceId}-description`;
---

<apg-dialog data-close-on-overlay={closeOnOverlayClick}>
  <!-- Trigger Button -->
  <button
    type="button"
    class={`apg-dialog-trigger ${triggerClass}`.trim()}
    data-dialog-trigger
  >
    {triggerText}
  </button>

  <!-- Native Dialog Element -->
  <dialog
    class={`apg-dialog ${className}`.trim()}
    aria-labelledby={titleId}
    aria-describedby={description ? descriptionId : undefined}
    data-dialog-content
  >
    <div class="apg-dialog-header">
      <h2 id={titleId} class="apg-dialog-title">
        {title}
      </h2>
      <button
        type="button"
        class="apg-dialog-close"
        data-dialog-close
        aria-label="Close dialog"
      >
        <svg
          xmlns="http://www.w3.org/2000/svg"
          width="24"
          height="24"
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          stroke-width="2"
          stroke-linecap="round"
          stroke-linejoin="round"
          aria-hidden="true"
        >
          <line x1="18" y1="6" x2="6" y2="18"></line>
          <line x1="6" y1="6" x2="18" y2="18"></line>
        </svg>
      </button>
    </div>
    {
      description && (
        <p id={descriptionId} class="apg-dialog-description">
          {description}
        </p>
      )
    }
    <div class="apg-dialog-body">
      <slot />
    </div>
  </dialog>
</apg-dialog>

<script>
  class ApgDialog extends HTMLElement {
    private trigger: HTMLButtonElement | null = null;
    private dialog: HTMLDialogElement | null = null;
    private closeButton: HTMLButtonElement | null = null;
    private closeOnOverlayClick = true;

    connectedCallback() {
      this.trigger = this.querySelector("[data-dialog-trigger]");
      this.dialog = this.querySelector("[data-dialog-content]");
      this.closeButton = this.querySelector("[data-dialog-close]");

      if (!this.trigger || !this.dialog) {
        console.warn("apg-dialog: required elements not found");
        return;
      }

      this.closeOnOverlayClick = this.dataset.closeOnOverlay !== "false";

      // Attach event listeners
      this.trigger.addEventListener("click", this.open);
      this.closeButton?.addEventListener("click", this.close);
      this.dialog.addEventListener("click", this.handleDialogClick);
      this.dialog.addEventListener("close", this.handleClose);
    }

    disconnectedCallback() {
      this.trigger?.removeEventListener("click", this.open);
      this.closeButton?.removeEventListener("click", this.close);
      this.dialog?.removeEventListener("click", this.handleDialogClick);
      this.dialog?.removeEventListener("close", this.handleClose);
    }

    private open = () => {
      if (!this.dialog) return;

      this.dialog.showModal();

      // Focus first focusable element (close button by default)
      const focusableElements = this.getFocusableElements(this.dialog);
      if (focusableElements.length > 0) {
        focusableElements[0].focus();
      }

      // Dispatch custom event
      this.dispatchEvent(
        new CustomEvent("dialogopen", {
          bubbles: true,
        })
      );
    };

    private close = () => {
      this.dialog?.close();
    };

    private handleClose = () => {
      // Return focus to trigger
      this.trigger?.focus();

      // Dispatch custom event
      this.dispatchEvent(
        new CustomEvent("dialogclose", {
          bubbles: true,
        })
      );
    };

    private handleDialogClick = (e: Event) => {
      // Close on backdrop click (clicking the dialog element itself, not its contents)
      if (this.closeOnOverlayClick && e.target === this.dialog) {
        this.close();
      }
    };

    private getFocusableElements(container: HTMLElement): HTMLElement[] {
      const focusableSelectors = [
        "a[href]",
        "button:not([disabled])",
        "input:not([disabled])",
        "select:not([disabled])",
        "textarea:not([disabled])",
        '[tabindex]:not([tabindex="-1"])',
      ].join(",");

      return Array.from(
        container.querySelectorAll<HTMLElement>(focusableSelectors)
      ).filter((el) => el.offsetParent !== null);
    }
  }

  // Register the custom element
  if (!customElements.get("apg-dialog")) {
    customElements.define("apg-dialog", ApgDialog);
  }
</script>

Usage

Example
---
import Dialog from './Dialog.astro';
---

<Dialog
  title="Dialog Title"
  description="Optional description text"
  triggerText="Open Dialog"
>
  <p>Dialog content goes here.</p>
</Dialog>

API

Props

Prop Type Default Description
title string required Dialog title (for accessibility)
description string - Optional description text
triggerText string required Text for the trigger button
closeOnOverlayClick boolean true Close when clicking overlay
triggerClass string - Additional CSS class for trigger button
class string - Additional CSS class for dialog

Slots

Slot Description
default Dialog content

Custom Events

Event Description
dialogopen Fired when the dialog opens
dialogclose Fired when the dialog closes

Resources