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

Click the buttons below to show alerts with different variants. The live region container exists in the DOM from page load - only the content changes.

Open demo only →

Accessibility Features

WAI-ARIA Roles

RoleTarget ElementDescription
alertAlert containerAn element that displays a brief, important message that attracts the user’s attention without interrupting their task

Implicit ARIA Properties

AttributeImplicit ValueDescription
aria-liveassertiveInterrupts screen reader to announce immediately
aria-atomictrueAnnounces entire alert content, not just changed parts

Keyboard Support

KeyAction
EnterActivates the dismiss button (if present)
SpaceActivates 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

EventBehavior
Alert must NOT move focusAlerts are non-modal and should not interrupt user workflow by stealing focus
Alert container is not focusableThe alert element should not have tabindex or receive keyboard focus
Dismiss button is focusableIf 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.tsx
import { cn } from '@/lib/utils';
import { Info, CircleCheck, AlertTriangle, OctagonAlert, X } from 'lucide-react';
import { useId, type ReactNode } from 'react';
import { type AlertVariant, variantStyles } from './alert-config';

export type { AlertVariant };

export interface AlertProps extends Omit<
  React.HTMLAttributes<HTMLDivElement>,
  'role' | 'children'
> {
  /**
   * Alert message content.
   * Changes to this prop trigger screen reader announcements.
   */
  message?: string;
  /**
   * Optional children for complex content.
   * Use message prop for simple text alerts.
   */
  children?: ReactNode;
  /**
   * 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;
  /**
   * Callback when alert is dismissed.
   * Should clear the message to hide the alert content.
   */
  onDismiss?: () => void;
}

const variantIcons: Record<AlertVariant, React.ReactNode> = {
  info: <Info className="size-5" />,
  success: <CircleCheck className="size-5" />,
  warning: <AlertTriangle className="size-5" />,
  error: <OctagonAlert className="size-5" />,
};

/**
 * Alert component following WAI-ARIA APG Alert Pattern
 *
 * IMPORTANT: The live region container (role="alert") is always present in the DOM.
 * Only the content inside changes dynamically - NOT the container itself.
 * This ensures screen readers properly announce alert messages.
 *
 * @see https://www.w3.org/WAI/ARIA/apg/patterns/alert/
 */
export const Alert: React.FC<AlertProps> = ({
  message,
  children,
  variant = 'info',
  id: providedId,
  className,
  dismissible = false,
  onDismiss,
  ...restProps
}) => {
  const generatedId = useId();
  const alertId = providedId ?? `alert-${generatedId}`;

  const content = message || children;
  const hasContent = Boolean(content);

  return (
    <div
      className={cn(
        'apg-alert',
        hasContent && [
          'relative flex items-start gap-3 rounded-lg border px-4 py-3',
          'transition-colors duration-150',
          variantStyles[variant],
        ],
        !hasContent && 'contents',
        className
      )}
      {...restProps}
    >
      {/* Live region - contains only content for screen reader announcement */}
      <div
        id={alertId}
        role="alert"
        className={cn(hasContent && 'flex flex-1 items-start gap-3', !hasContent && 'contents')}
      >
        {hasContent && (
          <>
            <span className="apg-alert-icon mt-0.5 flex-shrink-0" aria-hidden="true">
              {variantIcons[variant]}
            </span>
            <span className="apg-alert-content flex-1">{content}</span>
          </>
        )}
      </div>
      {/* Dismiss button - outside live region to avoid SR announcing it as part of alert */}
      {hasContent && dismissible && (
        <button
          type="button"
          className={cn(
            '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'
          )}
          onClick={onDismiss}
          aria-label="Dismiss alert"
        >
          <X className="size-5" aria-hidden="true" />
        </button>
      )}
    </div>
  );
};

export default Alert;

Usage

Example
import { useState } from 'react';
import { Alert } from './Alert';

function App() {
  const [message, setMessage] = useState('');

  return (
    <div>
      {/* IMPORTANT: Alert container is always in DOM */}
      <Alert
        message={message}
        variant="info"
        dismissible
        onDismiss={() => setMessage('')}
      />

      <button onClick={() => setMessage('Operation completed!')}>
        Show Alert
      </button>
    </div>
  );
}

API

Prop Type Default Description
message string - Alert message content
children ReactNode - Complex content (alternative to message)
variant 'info' | 'success' | 'warning' | 'error' 'info' Visual style variant
dismissible boolean false Show dismiss button
onDismiss () => void - Callback when dismissed
id string auto-generated Custom ID for SSR
className string - Additional CSS classes

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)

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 (Unit + E2E)

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 (Unit)

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

Low Priority : Cross-framework Consistency (E2E)

Test Feature
All frameworks have alert React, Vue, Svelte, Astro all render alert element
Same trigger buttons All frameworks have consistent trigger buttons
Show alert on click All 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 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.

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 testing-strategy.md (opens in new tab) for full documentation.

Alert.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import { Alert } from './Alert';

describe('Alert', () => {
  // High Priority: APG Core Compliance
  describe('APG: ARIA Attributes', () => {
    it('has role="alert"', () => {
      render(<Alert message="Test message" />);
      expect(screen.getByRole('alert')).toBeInTheDocument();
    });

    it('role=alert container exists in DOM even without message', () => {
      render(<Alert />);
      expect(screen.getByRole('alert')).toBeInTheDocument();
    });

    it('container remains the same element when message changes', () => {
      const { rerender } = render(<Alert message="First message" />);
      const alertElement = screen.getByRole('alert');
      const alertId = alertElement.id;

      rerender(<Alert message="Second message" />);
      expect(screen.getByRole('alert')).toHaveAttribute('id', alertId);
      expect(screen.getByRole('alert')).toHaveTextContent('Second message');
    });

    it('container remains when message is cleared', () => {
      const { rerender } = render(<Alert message="Test message" />);
      expect(screen.getByRole('alert')).toHaveTextContent('Test message');

      rerender(<Alert message="" />);
      expect(screen.getByRole('alert')).toBeInTheDocument();
      expect(screen.getByRole('alert')).not.toHaveTextContent('Test message');
    });
  });

  describe('APG: Focus Management', () => {
    it('does not move focus when alert is displayed', async () => {
      const user = userEvent.setup();
      render(
        <>
          <button>Other button</button>
          <Alert message="Test message" />
        </>
      );

      const button = screen.getByRole('button', { name: 'Other button' });
      await user.click(button);
      expect(button).toHaveFocus();

      // Focus should not move when alert is displayed
      expect(button).toHaveFocus();
    });

    it('alert itself does not receive focus (no tabindex)', () => {
      render(<Alert message="Test message" />);
      expect(screen.getByRole('alert')).not.toHaveAttribute('tabindex');
    });
  });

  describe('Dismiss Feature', () => {
    it('shows dismiss button when dismissible=true', () => {
      render(<Alert message="Test message" dismissible />);
      expect(screen.getByRole('button', { name: 'Dismiss alert' })).toBeInTheDocument();
    });

    it('does not show dismiss button when dismissible=false (default)', () => {
      render(<Alert message="Test message" />);
      expect(screen.queryByRole('button', { name: 'Dismiss alert' })).not.toBeInTheDocument();
    });

    it('calls onDismiss when dismiss button is clicked', async () => {
      const handleDismiss = vi.fn();
      const user = userEvent.setup();
      render(<Alert message="Test message" dismissible onDismiss={handleDismiss} />);

      await user.click(screen.getByRole('button', { name: 'Dismiss alert' }));
      expect(handleDismiss).toHaveBeenCalledTimes(1);
    });

    it('dismiss button has type=button', () => {
      render(<Alert message="Test message" dismissible />);
      expect(screen.getByRole('button', { name: 'Dismiss alert' })).toHaveAttribute(
        'type',
        'button'
      );
    });

    it('dismiss button has aria-label', () => {
      render(<Alert message="Test message" dismissible />);
      expect(screen.getByRole('button', { name: 'Dismiss alert' })).toHaveAccessibleName(
        'Dismiss alert'
      );
    });
  });

  // Medium Priority: Accessibility Validation
  describe('Accessibility', () => {
    it('has no WCAG 2.1 AA violations (with message)', async () => {
      const { container } = render(<Alert message="Test message" />);
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no WCAG 2.1 AA violations (without message)', async () => {
      const { container } = render(<Alert />);
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no WCAG 2.1 AA violations (dismissible)', async () => {
      const { container } = render(
        <Alert message="Test message" dismissible onDismiss={() => {}} />
      );
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });
  });

  describe('Variant Styles', () => {
    it.each(['info', 'success', 'warning', 'error'] as const)(
      'applies appropriate style class for variant=%s',
      (variant) => {
        render(<Alert message="Test message" variant={variant} />);
        const alert = screen.getByRole('alert');
        // apg-alert class is on the parent wrapper, not on role="alert"
        const wrapper = alert.parentElement;
        expect(wrapper).toHaveClass('apg-alert');
      }
    );

    it('default variant is info', () => {
      render(<Alert message="Test message" />);
      const alert = screen.getByRole('alert');
      // info variant style is applied to the parent wrapper
      const wrapper = alert.parentElement;
      expect(wrapper).toHaveClass('bg-blue-50');
    });
  });

  // Low Priority: Props & Extensibility
  describe('Props', () => {
    it('can set custom ID with id prop', () => {
      render(<Alert message="Test message" id="custom-alert-id" />);
      expect(screen.getByRole('alert')).toHaveAttribute('id', 'custom-alert-id');
    });

    it('merges className correctly', () => {
      render(<Alert message="Test message" className="custom-class" />);
      const alert = screen.getByRole('alert');
      // className is applied to the parent wrapper
      const wrapper = alert.parentElement;
      expect(wrapper).toHaveClass('apg-alert');
      expect(wrapper).toHaveClass('custom-class');
    });

    it('can pass complex content via children', () => {
      render(
        <Alert>
          <strong>Important:</strong> This is a message
        </Alert>
      );
      expect(screen.getByRole('alert')).toHaveTextContent('Important: This is a message');
    });

    it('message takes priority when both message and children are provided', () => {
      render(
        <Alert message="Message prop">
          <span>Children content</span>
        </Alert>
      );
      expect(screen.getByRole('alert')).toHaveTextContent('Message prop');
      expect(screen.getByRole('alert')).not.toHaveTextContent('Children content');
    });
  });

  describe('HTML Attribute Inheritance', () => {
    it('can pass additional HTML attributes', () => {
      render(<Alert message="Test" data-testid="custom-alert" />);
      expect(screen.getByTestId('custom-alert')).toBeInTheDocument();
    });
  });
});

Resources