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.

Open demo only →

Accessibility Features

WAI-ARIA Roles

Role Target Element Description
alert Alert container An element that displays a brief, important message that attracts the user’s attention without interrupting their task

Implicit ARIA Properties

Attribute 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

Key Action
Enter Activates the dismiss button (if present)
Space Activates the dismiss button (if present)
  • 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.

Focus Management

Event Behavior
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

Implementation Notes

<!-- Container always in DOM -->
<div role="alert">
  <!-- Content added dynamically -->
  <span>Your changes have been saved.</span>
</div>

Announcement Behavior:
- Page load content: NOT announced
- Dynamic changes: ANNOUNCED immediately
- aria-live="assertive": interrupts current speech

Alert vs Status:
┌─────────────┬──────────────────────┐
│ role="alert"│ role="status"        │
├─────────────┼──────────────────────┤
│ assertive   │ polite               │
│ interrupts  │ waits for pause      │
│ urgent info │ non-urgent updates   │
└─────────────┴──────────────────────┘

Alert component structure and announcement behavior

Use Alert

  • 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”)

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

Important Notes

  • 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.

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 rounded-lg border px-4 py-3',
      '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 flex-1 items-start gap-3', !hasContent && 'contents']}
  >
    {
      hasContent && (
        <>
          <span class="apg-alert-icon mt-0.5 flex-shrink-0" 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',
          '-m-2 min-h-11 min-w-11 flex-shrink-0 rounded p-2',
          'flex items-center justify-center',
          'hover:bg-black/10 dark:hover:bg-white/10',
          'focus:ring-2 focus:ring-current focus:ring-offset-2 focus:outline-none',
        ]}
        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

PropTypeDefaultDescription
messagestring''Initial alert message
variant'info' | 'success' | 'warning' | 'error''info'Visual style variant
dismissiblebooleanfalseShow dismiss button
idstringauto-generatedCustom ID
classstring-Additional CSS classes
This component uses a Web Component (<apg-alert>) for client-side interactivity. Use setMessage(message, variant?) method to update alert message programmatically.

Custom Events

EventDetailDescription
dismiss-Fired when dismiss button is clicked

Testing

Tests verify APG compliance for live region behavior, ARIA attributes, and accessibility requirements. The Alert component uses a two-layer testing strategy.

Testing Strategy

Unit Tests (Testing Library)

Verify the component's rendered output using framework-specific testing libraries. These tests ensure correct HTML structure and ARIA attributes.

  • ARIA attributes (role="alert")
  • Live region container persistence in DOM
  • Dismiss button accessibility
  • Accessibility via jest-axe

E2E Tests (Playwright)

Verify component behavior in a real browser environment across all frameworks. These tests cover interactions and cross-framework consistency.

  • ARIA structure in live browser
  • Focus management (alert does NOT steal focus)
  • Dismiss button keyboard interactions
  • Tab navigation behavior
  • axe-core accessibility scanning
  • Cross-framework consistency checks

Test Categories

High Priority: APG Core Compliance (Unit + E2E)

TestAPG Requirement
role="alert" existsAlert container must have alert role
Container always in DOMLive region must not be dynamically added/removed
Same container on message changeContainer element identity preserved during updates
Focus unchanged after alertAlert must not move keyboard focus
Alert not focusableAlert container must not have tabindex

Medium Priority: Accessibility Validation (Unit + E2E)

TestWCAG 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 nameButton has aria-label
Dismiss button type="button"Prevents form submission

Low Priority: Props & Extensibility (Unit)

TestFeature
variant prop changes stylingVisual customization
id prop sets custom IDSSR support
className inheritanceStyle customization
children for complex contentContent flexibility
onDismiss callback firesEvent handling

Low Priority: Cross-framework Consistency (E2E)

TestFeature
All frameworks have alertReact, Vue, Svelte, Astro all render alert element
Same trigger buttonsAll frameworks have consistent trigger buttons
Show alert on clickAll frameworks show alert when button is clicked

Screen Reader Testing

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

Screen ReaderPlatform
VoiceOvermacOS / iOS
NVDAWindows
JAWSWindows
TalkBackAndroid

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

Testing Tools

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

See the Testing Strategy guide for details.

Resources