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.
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
-
alert- An element that displays a brief, important message that attracts the user's attention without interrupting their task
WAI-ARIA alert role (opens in new tab)
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
tabindexor 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
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 px-4 py-3 rounded-lg border",
"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-1 flex items-start gap-3",
!hasContent && "contents"
)}
>
{hasContent && (
<>
<span className="apg-alert-icon flex-shrink-0 mt-0.5" 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",
"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"
)}
onClick={onDismiss}
aria-label="Dismiss alert"
>
<X className="size-5" aria-hidden="true" />
</button>
)}
</div>
);
};
export default Alert; Usage
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
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 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 属性", () => {
it('role="alert" を持つ', () => {
render(<Alert message="Test message" />);
expect(screen.getByRole("alert")).toBeInTheDocument();
});
it("メッセージがなくても role=alert コンテナは DOM に存在する", () => {
render(<Alert />);
expect(screen.getByRole("alert")).toBeInTheDocument();
});
it("メッセージ変更時もコンテナは同じ要素のまま", () => {
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("メッセージがクリアされてもコンテナは残る", () => {
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: フォーカス管理", () => {
it("アラート表示時にフォーカスを移動しない", 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();
// アラートが表示されてもフォーカスは移動しない
expect(button).toHaveFocus();
});
it("アラート自体はフォーカスを受け取らない(tabindex なし)", () => {
render(<Alert message="Test message" />);
expect(screen.getByRole("alert")).not.toHaveAttribute("tabindex");
});
});
describe("Dismiss 機能", () => {
it("dismissible=true で閉じるボタンが表示される", () => {
render(<Alert message="Test message" dismissible />);
expect(
screen.getByRole("button", { name: "Dismiss alert" })
).toBeInTheDocument();
});
it("dismissible=false(デフォルト)で閉じるボタンが表示されない", () => {
render(<Alert message="Test message" />);
expect(
screen.queryByRole("button", { name: "Dismiss alert" })
).not.toBeInTheDocument();
});
it("閉じるボタンクリックで onDismiss が呼び出される", 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("閉じるボタンは type=button を持つ", () => {
render(<Alert message="Test message" dismissible />);
expect(screen.getByRole("button", { name: "Dismiss alert" })).toHaveAttribute(
"type",
"button"
);
});
it("閉じるボタンに aria-label がある", () => {
render(<Alert message="Test message" dismissible />);
expect(screen.getByRole("button", { name: "Dismiss alert" })).toHaveAccessibleName(
"Dismiss alert"
);
});
});
// Medium Priority: Accessibility Validation
describe("アクセシビリティ", () => {
it("axe による WCAG 2.1 AA 違反がない(メッセージあり)", async () => {
const { container } = render(<Alert message="Test message" />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it("axe による WCAG 2.1 AA 違反がない(メッセージなし)", async () => {
const { container } = render(<Alert />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it("axe による WCAG 2.1 AA 違反がない(dismissible)", async () => {
const { container } = render(
<Alert message="Test message" dismissible onDismiss={() => {}} />
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
describe("Variant スタイル", () => {
it.each(["info", "success", "warning", "error"] as const)(
"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("デフォルトの variant は info", () => {
render(<Alert message="Test message" />);
const alert = screen.getByRole("alert");
// info variant のスタイルが親ラッパーに適用されている
const wrapper = alert.parentElement;
expect(wrapper).toHaveClass("bg-blue-50");
});
});
// Low Priority: Props & Extensibility
describe("Props", () => {
it("id prop でカスタム ID を設定できる", () => {
render(<Alert message="Test message" id="custom-alert-id" />);
expect(screen.getByRole("alert")).toHaveAttribute("id", "custom-alert-id");
});
it("className が正しくマージされる", () => {
render(<Alert message="Test message" className="custom-class" />);
const alert = screen.getByRole("alert");
// className は親ラッパーに適用される
const wrapper = alert.parentElement;
expect(wrapper).toHaveClass("apg-alert");
expect(wrapper).toHaveClass("custom-class");
});
it("children で複雑なコンテンツを渡せる", () => {
render(
<Alert>
<strong>Important:</strong> This is a message
</Alert>
);
expect(screen.getByRole("alert")).toHaveTextContent(
"Important: This is a message"
);
});
it("message と children 両方ある場合は message が優先される", () => {
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 属性継承", () => {
it("追加の HTML 属性が渡せる", () => {
render(<Alert message="Test" data-testid="custom-alert" />);
expect(screen.getByTestId("custom-alert")).toBeInTheDocument();
});
});
});