APG Patterns

Alert

An element that displays a brief, important message in a way that attracts the user's attention without interrupting the user's task.

Demo

The Astro implementation uses a Web Component that provides a setMessage() method for updating alert content. The live region container exists in the DOM from page load - only the content changes.

Accessibility Features

Critical Implementation Note

The live region container (role="alert") must exist in the DOM from page load. Do NOT dynamically add/remove the container itself. Only the content inside the container should change dynamically.

// Incorrect: Dynamically adding live region
{showAlert && <div role="alert">Message</div>}

// Correct: Live region always exists, content changes
<div role="alert">
  {message && <span>{message}</span>}
</div>

Screen readers detect changes to live regions by observing DOM mutations inside them. If the live region itself is added dynamically, some screen readers may not announce the content reliably.

WAI-ARIA Roles

Implicit ARIA Properties

The role="alert" implicitly sets the following ARIA properties. You do NOT need to add these manually:

Property Implicit Value Description
aria-live assertive Interrupts screen reader to announce immediately
aria-atomic true Announces entire alert content, not just changed parts

Keyboard Support

Alerts require no keyboard interaction. They are designed to inform users without interrupting their workflow. The alert content is automatically announced by screen readers when it changes.

If the alert includes a dismiss button, the button follows standard button keyboard interaction:

Key Action
Enter Activates the dismiss button
Space Activates the dismiss button

Focus Management

  • Alert must NOT move focus - Alerts are non-modal and should not interrupt user workflow by stealing focus.
  • Alert container is not focusable - The alert element should not have tabindex or receive keyboard focus.
  • Dismiss button is focusable - If present, the dismiss button can be reached via Tab navigation.

Important Guidelines

No Auto-Dismissal

Alerts should NOT disappear automatically. Per WCAG 2.2.3 No Timing (opens in new tab) , users need sufficient time to read content. If auto-dismissal is required:

  • Provide user control to pause/extend the display time
  • Ensure sufficient display duration (minimum 5 seconds + reading time)
  • Consider if the content is truly non-essential

Alert Frequency

Excessive alerts can inhibit usability, especially for users with visual or cognitive disabilities ( WCAG 2.2.4 Interruptions (opens in new tab) ). Use alerts sparingly for truly important messages.

Alert vs Alert Dialog

Use Alert when:

  • The message is informational and doesn't require user action
  • User workflow should NOT be interrupted
  • Focus should remain on the current task

Use Alert Dialog (role="alertdialog") when:

  • The message requires immediate user response
  • User must acknowledge or take action before continuing
  • Focus should move to the dialog (modal behavior)

Note: role="alertdialog" requires focus management and keyboard handling (Escape to close, focus trapping). Only use it when modal interruption is appropriate.

Screen Reader Behavior

  • Immediate announcement - When alert content changes, screen readers interrupt current reading to announce the alert (aria-live="assertive").
  • Full content announced - The entire alert content is read, not just the changed portion (aria-atomic="true").
  • Initial content not announced - Alerts that exist when the page loads are NOT automatically announced. Only dynamic changes trigger announcements.

References

Source Code

Alert.astro
---
import InfoIcon from "lucide-static/icons/info.svg";
import CircleCheckIcon from "lucide-static/icons/circle-check.svg";
import AlertTriangleIcon from "lucide-static/icons/triangle-alert.svg";
import OctagonAlertIcon from "lucide-static/icons/octagon-alert.svg";
import XIcon from "lucide-static/icons/x.svg";
import { type AlertVariant, variantStyles as sharedVariantStyles } from "./alert-config";

export type { AlertVariant };

export interface Props {
  /**
   * Alert message content.
   * Changes to this prop trigger screen reader announcements.
   */
  message?: string;
  /**
   * Alert variant for visual styling.
   * Does NOT affect ARIA - all variants use role="alert"
   */
  variant?: AlertVariant;
  /**
   * Custom ID for the alert container.
   * Useful for SSR/hydration consistency.
   */
  id?: string;
  /**
   * Whether to show dismiss button.
   * Note: Manual dismiss only - NO auto-dismiss per WCAG 2.2.3
   */
  dismissible?: boolean;
  /**
   * Additional class name for the alert container
   */
  class?: string;
}

const {
  message = "",
  variant = "info",
  id,
  dismissible = false,
  class: className = "",
} = Astro.props;

const alertId = id ?? `alert-${crypto.randomUUID().slice(0, 8)}`;

const variantIcons = {
  info: InfoIcon,
  success: CircleCheckIcon,
  warning: AlertTriangleIcon,
  error: OctagonAlertIcon,
};

const IconComponent = variantIcons[variant];
const hasContent = Boolean(message);
---

<apg-alert
  class:list={[
    "apg-alert",
    hasContent && [
      "relative flex items-start gap-3 px-4 py-3 rounded-lg border",
      "transition-colors duration-150",
      sharedVariantStyles[variant],
    ],
    !hasContent && "contents",
    className,
  ]}
  data-alert-id={alertId}
  data-dismissible={dismissible ? "true" : undefined}
  data-variant={variant}
>
  {/* Live region - contains only content for screen reader announcement */}
  <div id={alertId} role="alert" class:list={[
    hasContent && "flex-1 flex items-start gap-3",
    !hasContent && "contents"
  ]}>
    {
      hasContent && (
        <>
          <span class="apg-alert-icon flex-shrink-0 mt-0.5" aria-hidden="true">
            <IconComponent class="size-5" />
          </span>
          <span class="apg-alert-content flex-1">{message}</span>
        </>
      )
    }
  </div>
  {/* Dismiss button - outside live region to avoid SR announcing it as part of alert */}
  {
    hasContent && dismissible && (
      <button
        type="button"
        class:list={[
          "apg-alert-dismiss",
          "flex-shrink-0 min-w-11 min-h-11 p-2 -m-2 rounded",
          "flex items-center justify-center",
          "hover:bg-black/10 dark:hover:bg-white/10",
          "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-current",
        ]}
        aria-label="Dismiss alert"
      >
        <XIcon class="size-5" aria-hidden="true" />
      </button>
    )
  }
</apg-alert>

<script>
  import {
    variantStyles,
    variantIconSvgs,
    dismissIconSvg,
  } from "./alert-config";

  class ApgAlert extends HTMLElement {
    private alertId: string = "";

    connectedCallback() {
      this.alertId = this.dataset.alertId ?? "";
      const dismissBtn = this.querySelector(".apg-alert-dismiss");

      if (dismissBtn) {
        dismissBtn.addEventListener("click", this.handleDismiss);
      }
    }

    disconnectedCallback() {
      const dismissBtn = this.querySelector(".apg-alert-dismiss");
      if (dismissBtn) {
        dismissBtn.removeEventListener("click", this.handleDismiss);
      }
    }

    private handleDismiss = () => {
      // Clear content but keep the alert container (live region)
      const alertEl = this.querySelector(`#${this.alertId}`);
      if (alertEl) {
        alertEl.replaceChildren();
        // Update live region classes
        alertEl.classList.remove("flex-1", "flex", "items-start", "gap-3");
        alertEl.classList.add("contents");
      }
      // Update custom element display
      this.className = "apg-alert contents";
      // Remove dismiss button
      const existingDismissBtn = this.querySelector(".apg-alert-dismiss");
      if (existingDismissBtn) {
        existingDismissBtn.remove();
      }

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

    /**
     * Method to update alert message programmatically.
     * The live region container remains in DOM - only content changes.
     */
    setMessage(message: string, variant?: string) {
      const alertEl = this.querySelector(`#${this.alertId}`);
      if (!alertEl) return;

      const currentVariant = (variant ?? this.dataset.variant ?? "info") as keyof typeof variantStyles;

      if (message) {
        // Update live region classes
        alertEl.classList.remove("contents");
        alertEl.classList.add("flex-1", "flex", "items-start", "gap-3");
        // Update custom element styles
        this.classList.remove("contents");
        this.className = `apg-alert relative flex items-start gap-3 px-4 py-3 rounded-lg border transition-colors duration-150 ${variantStyles[currentVariant]}`;

        // Build DOM nodes safely (avoid innerHTML for user content)
        const iconSpan = document.createElement("span");
        iconSpan.className = "apg-alert-icon flex-shrink-0 mt-0.5";
        iconSpan.setAttribute("aria-hidden", "true");
        iconSpan.innerHTML = variantIconSvgs[currentVariant]; // Safe: hardcoded SVG

        const contentSpan = document.createElement("span");
        contentSpan.className = "apg-alert-content flex-1";
        contentSpan.textContent = message; // Safe: uses textContent

        // Update live region content (icon + message only)
        alertEl.replaceChildren(iconSpan, contentSpan);

        // Remove existing dismiss button if any
        const existingDismissBtn = this.querySelector(".apg-alert-dismiss");
        if (existingDismissBtn) {
          existingDismissBtn.remove();
        }

        // Add dismiss button outside live region
        if (this.dataset.dismissible === "true") {
          const dismissBtn = document.createElement("button");
          dismissBtn.type = "button";
          dismissBtn.className = "apg-alert-dismiss flex-shrink-0 min-w-11 min-h-11 p-2 -m-2 rounded flex items-center justify-center hover:bg-black/10 dark:hover:bg-white/10 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-current";
          dismissBtn.setAttribute("aria-label", "Dismiss alert");
          dismissBtn.innerHTML = dismissIconSvg; // Safe: hardcoded SVG
          dismissBtn.addEventListener("click", this.handleDismiss);
          this.appendChild(dismissBtn);
        }
      } else {
        alertEl.replaceChildren();
        // Update live region classes
        alertEl.classList.remove("flex-1", "flex", "items-start", "gap-3");
        alertEl.classList.add("contents");
        // Update custom element display
        this.className = "apg-alert contents";
        // Remove dismiss button
        const existingDismissBtn = this.querySelector(".apg-alert-dismiss");
        if (existingDismissBtn) {
          existingDismissBtn.remove();
        }
      }
    }
  }

  customElements.define("apg-alert", ApgAlert);
</script>

Usage

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

<!-- IMPORTANT: Alert container is always in DOM -->
<Alert
  id="my-alert"
  variant="info"
  dismissible
/>

<button onclick="document.querySelector('apg-alert').setMessage('Hello!')">
  Show Alert
</button>

<script>
  // Listen for dismiss events
  document.querySelector('apg-alert')?.addEventListener('dismiss', () => {
    console.log('Alert dismissed');
  });
</script>

API

Props

Prop Type Default Description
message string '' Initial alert message
variant 'info' | 'success' | 'warning' | 'error' 'info' Visual style variant
dismissible boolean false Show dismiss button
id string auto-generated Custom ID
class string - Additional CSS classes

Methods

Method Description
setMessage(message, variant?) Update alert message programmatically. The container remains in DOM.

Events

Event Description
dismiss Fired when dismiss button is clicked

Testing

Testing Overview

The Alert component tests focus on verifying correct live region behavior and APG compliance. The most critical test is ensuring the alert container remains in the DOM when content changes.

Test Categories

High Priority: APG Core Compliance

Test APG Requirement
role="alert" exists Alert container must have alert role
Container always in DOM Live region must not be dynamically added/removed
Same container on message change Container element identity preserved during updates
Focus unchanged after alert Alert must not move keyboard focus
Alert not focusable Alert container must not have tabindex

Medium Priority: Accessibility Validation

Test WCAG Requirement
No axe violations (with message) WCAG 2.1 AA compliance
No axe violations (empty) WCAG 2.1 AA compliance
No axe violations (dismissible) WCAG 2.1 AA compliance
Dismiss button accessible name Button has aria-label
Dismiss button type="button" Prevents form submission

Low Priority: Props & Extensibility

Test Feature
variant prop changes styling Visual customization
id prop sets custom ID SSR support
className inheritance Style customization
children for complex content Content flexibility
onDismiss callback fires Event handling

Screen Reader Testing

Automated tests verify DOM structure, but manual testing with screen readers is essential for validating actual announcement behavior:

Screen Reader Platform
VoiceOver macOS / iOS
NVDA Windows
JAWS Windows
TalkBack Android

Verify that message changes trigger immediate announcement and that existing content on page load is NOT announced.

Running Tests

# Run all Alert tests
npm run test -- alert

# Run tests for specific framework
npm run test -- Alert.test.tsx    # React
npm run test -- Alert.test.vue    # Vue
npm run test -- Alert.test.svelte # Svelte

Resources