Alert
ユーザーのタスクを中断せずに、重要なメッセージを目立つ形で表示する要素。
🤖 AI 実装ガイドデモ
下のボタンをクリックして、異なるバリアントのアラートを表示します。ライブリージョンコンテナはページ読み込み時からDOMに存在し、コンテンツのみが変化します。
アクセシビリティ
重要な実装上の注意
ライブリージョンのコンテナ(role="alert")は、ページ読み込み時から DOM
に存在している必要があります。
コンテナ自体を動的に追加・削除しないでください。コンテナ内のコンテンツのみを動的に変更するようにしてください。
// 誤り: ライブリージョンを動的に追加
{showAlert && <div role="alert">Message</div>}
// 正しい: ライブリージョンは常に存在し、コンテンツを変更
<div role="alert">
{message && <span>{message}</span>}
</div> スクリーンリーダーは、ライブリージョン内の DOM の変更を検知してアナウンスします。ライブリージョン自体が動的に追加される場合、一部のスクリーンリーダーではコンテンツが確実にアナウンスされない可能性があります。
WAI-ARIA ロール
-
alert- ユーザーのタスクを中断することなく、ユーザーの注意を引く簡潔で重要なメッセージを表示する要素
WAI-ARIA alert role (opens in new tab)
暗黙的な ARIA プロパティ
role="alert" は以下の ARIA プロパティを暗黙的に設定します。これらを手動で追加する必要はありません:
| プロパティ | 暗黙的な値 | 説明 |
|---|---|---|
aria-live | assertive | スクリーンリーダーを中断して即座にアナウンス |
aria-atomic | true | 変更された部分だけでなく、アラート全体のコンテンツをアナウンス |
キーボードサポート
アラートはキーボード操作を必要としません。ユーザーのワークフローを中断することなく情報を伝えることを目的としています。アラートのコンテンツは、変更されると自動的にスクリーンリーダーによってアナウンスされます。
アラートに閉じるボタンが含まれる場合、ボタンは標準的なボタンのキーボード操作に従います:
| キー | アクション |
|---|---|
| Enter | 閉じるボタンをアクティブ化 |
| Space | 閉じるボタンをアクティブ化 |
フォーカス管理
- アラートはフォーカスを移動してはいけません - アラートは非モーダルであり、フォーカスを奪うことでユーザーのワークフローを中断してはいけません。
- アラートコンテナはフォーカス不可 - アラート要素は
tabindexを持たず、キーボードフォーカスを受け取ってはいけません。 - 閉じるボタンはフォーカス可能 - 存在する場合、閉じるボタンは Tab ナビゲーションで到達可能です。
重要なガイドライン
自動非表示の禁止
アラートは自動的に消えてはいけません。 WCAG 2.2.3 制限時間なし (opens in new tab) に従い、ユーザーがコンテンツを読むのに十分な時間が必要です。自動非表示が必要な場合:
- 表示時間を一時停止・延長するためのユーザーコントロールを提供する
- 十分な表示時間を確保する(最低5秒 + 読む時間)
- コンテンツが本当に必須でないかを検討する
アラートの頻度
過度なアラートは、特に視覚障がいや認知障がいを持つユーザーにとって使いやすさを損なう可能性があります( WCAG 2.2.4 割り込み (opens in new tab) )。本当に重要なメッセージのためだけにアラートを使用してください。
Alert vs Alert Dialog
Alert を使用する場合:
- メッセージが情報提供のみでユーザーアクションを必要としない
- ユーザーのワークフローを中断すべきでない
- フォーカスは現在のタスクに留まるべき
Alert Dialog (role="alertdialog") を使用する場合:
- メッセージが即座のユーザー応答を必要とする
- ユーザーが続行する前に確認またはアクションをとる必要がある
- フォーカスをダイアログに移動すべき(モーダル動作)
注意: role="alertdialog" にはフォーカス管理とキーボード処理(Escapeで閉じる、フォーカストラップ)が必要です。モーダルな中断が適切な場合にのみ使用してください。
スクリーンリーダーの動作
- 即座のアナウンス - アラートのコンテンツが変更されると、スクリーンリーダーは現在の読み上げを中断してアラートをアナウンスします(
aria-live="assertive")。 - 完全なコンテンツのアナウンス - 変更された部分だけでなく、アラート全体のコンテンツが読み上げられます(
aria-atomic="true")。 - 初期コンテンツはアナウンスされない - ページ読み込み時に存在するアラートは自動的にアナウンスされません。動的な変更のみがアナウンスをトリガーします。
参考資料
ソースコード
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; 使い方
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
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
message | string | - | アラートメッセージの内容 |
children | ReactNode | - | 複雑なコンテンツ(messageの代替) |
variant | 'info' | 'success' | 'warning' | 'error' | 'info' | 視覚スタイルのバリアント |
dismissible | boolean | false | 閉じるボタンを表示 |
onDismiss | () => void | - | 閉じられた時のコールバック |
id | string | auto-generated | SSR用のカスタムID |
className | string | - | 追加のCSSクラス |
テスト
テスト概要
Alert コンポーネントのテストは、正しいライブリージョンの動作と APG 準拠の検証に焦点を当てています。最も重要なテストは、コンテンツが変更されたときにアラートコンテナが DOM に残ることを確認することです。
テストカテゴリ
高優先度: APG コア準拠
| テスト | APG 要件 |
|---|---|
| role="alert" の存在 | アラートコンテナは alert ロールを持つ必要がある |
| コンテナが常に DOM に存在 | ライブリージョンは動的に追加・削除してはならない |
| メッセージ変更時も同じコンテナ | 更新時にコンテナ要素の同一性が保持される |
| アラート後もフォーカスは変わらない | アラートはキーボードフォーカスを移動してはならない |
| アラートはフォーカス不可 | アラートコンテナは tabindex を持ってはならない |
中優先度: アクセシビリティ検証
| テスト | WCAG 要件 |
|---|---|
| axe 違反なし(メッセージあり) | WCAG 2.1 AA 準拠 |
| axe 違反なし(空) | WCAG 2.1 AA 準拠 |
| axe 違反なし(閉じるボタンあり) | WCAG 2.1 AA 準拠 |
| 閉じるボタンにアクセシブルな名前 | ボタンは aria-label を持つ |
| 閉じるボタンは type="button" | フォーム送信を防ぐ |
低優先度: Props と拡張性
| テスト | 機能 |
|---|---|
| variant prop でスタイルを変更 | ビジュアルのカスタマイズ |
| id prop でカスタム ID を設定 | SSR サポート |
| className の継承 | スタイルのカスタマイズ |
| 複雑なコンテンツの children | コンテンツの柔軟性 |
| onDismiss コールバックが発火 | イベント処理 |
スクリーンリーダーテスト
自動テストは DOM 構造を検証しますが、実際のアナウンス動作を検証するにはスクリーンリーダーによる手動テストが不可欠です:
| スクリーンリーダー | プラットフォーム |
|---|---|
| VoiceOver | macOS / iOS |
| NVDA | Windows |
| JAWS | Windows |
| TalkBack | Android |
メッセージの変更が即座のアナウンスをトリガーすること、およびページ読み込み時に存在するコンテンツはアナウンスされないことを確認してください。
テストツール
- Vitest (opens in new tab) - ユニットテストランナー
- Testing Library (opens in new tab) - フレームワーク別テストユーティリティ(React、Vue、Svelte)
- Playwright (opens in new tab) - E2Eテスト用ブラウザ自動化
- axe-core/playwright (opens in new tab) - E2Eでの自動アクセシビリティテスト
テストの実行
# すべての Alert テストを実行
npm run test -- alert
# 特定のフレームワークのテストを実行
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 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();
});
});
}); リソース
- WAI-ARIA APG: Alert パターン (opens in new tab)
- WAI-ARIA APG: Alert Dialog Pattern (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist