Webアクセシビリティ実装ガイド2026|WCAG準拠・WAI-ARIA・テスト自動化
なぜアクセシビリティが重要なのか
Webアクセシビリティとは、障害の有無に関わらずすべての人がWebコンテンツを利用できるようにすることです。
2026年現在、アクセシビリティは「あると良い」ではなくビジネス要件になりつつあります。
- 法的義務: EU(European Accessibility Act 2025年施行)、日本(障害者差別解消法)
- SEO効果: セマンティックHTMLはGoogleのクローリング精度を向上
- ユーザー拡大: 世界人口の15%(約10億人)が何らかの障害を持つ
- UX向上: アクセシビリティ改善はすべてのユーザーの体験を改善
WCAG 2.2 の4原則
WCAG(Web Content Accessibility Guidelines)は4つの原則に基づいています:
1. 知覚可能(Perceivable)
コンテンツをユーザーが知覚できること。
<!-- ❌ 画像にalt属性がない -->
<img src="chart.png">
<!-- ✅ 適切な代替テキスト -->
<img src="chart.png" alt="2026年Q1の売上推移グラフ。1月100万円、2月120万円、3月150万円">
<!-- ✅ 装飾画像は空のalt -->
<img src="decorative-border.png" alt="">
動画のアクセシビリティ
<video controls>
<source src="tutorial.mp4" type="video/mp4">
<!-- 字幕トラック -->
<track kind="captions" src="captions-ja.vtt" srclang="ja" label="日本語" default>
<!-- 音声解説 -->
<track kind="descriptions" src="descriptions-ja.vtt" srclang="ja" label="音声解説">
</video>
色だけに依存しない情報伝達
/* ❌ 色だけでエラーを表現 */
.error { color: red; }
/* ✅ 色 + アイコン + テキストで表現 */
.error {
color: #d32f2f;
border-left: 4px solid #d32f2f;
padding-left: 12px;
}
.error::before {
content: "⚠ ";
}
2. 操作可能(Operable)
すべてのUIをキーボードで操作できること。
// ❌ クリックのみ対応
<div onClick={handleAction}>アクション</div>
// ✅ キーボードアクセシブル
<button onClick={handleAction}>アクション</button>
// ✅ カスタム要素をキーボード対応にする場合
<div
role="button"
tabIndex={0}
onClick={handleAction}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleAction();
}
}}
>
アクション
</div>
フォーカス管理
/* ❌ フォーカスインジケータを消す */
*:focus { outline: none; }
/* ✅ 見やすいフォーカススタイル */
*:focus-visible {
outline: 3px solid #2563eb;
outline-offset: 2px;
border-radius: 2px;
}
スキップリンク
<!-- ページ最上部に配置 -->
<a href="#main-content" class="skip-link">
メインコンテンツにスキップ
</a>
<style>
.skip-link {
position: absolute;
top: -40px;
left: 0;
padding: 8px 16px;
background: #000;
color: #fff;
z-index: 100;
}
.skip-link:focus {
top: 0;
}
</style>
3. 理解可能(Understandable)
コンテンツが理解できること。
<!-- 言語の指定 -->
<html lang="ja">
<!-- 部分的に異なる言語 -->
<p>これは<span lang="en">accessibility</span>に関する記事です。</p>
エラーメッセージ
// ✅ 具体的なエラーメッセージ
<div role="alert">
<p>入力内容に問題があります:</p>
<ul>
<li>メールアドレスの形式が正しくありません(例: user@example.com)</li>
<li>パスワードは8文字以上必要です(現在5文字)</li>
</ul>
</div>
4. 堅牢(Robust)
さまざまな支援技術で解釈できること。
<!-- ✅ セマンティックHTML -->
<header>
<nav aria-label="メインナビゲーション">...</nav>
</header>
<main>
<article>
<h1>記事タイトル</h1>
<section aria-labelledby="section-1">
<h2 id="section-1">セクション1</h2>
</section>
</article>
<aside aria-label="関連記事">...</aside>
</main>
<footer>...</footer>
WAI-ARIA 実践パターン
ライブリージョン(動的コンテンツの通知)
// トースト通知
function Toast({ message }: { message: string }) {
return (
<div
role="alert"
aria-live="assertive"
aria-atomic="true"
>
{message}
</div>
);
}
// 検索結果の件数通知
function SearchResults({ count }: { count: number }) {
return (
<div aria-live="polite" aria-atomic="true">
{count}件の結果が見つかりました
</div>
);
}
モーダルダイアログ
function Modal({ isOpen, onClose, title, children }) {
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
if (isOpen) {
dialogRef.current?.showModal();
} else {
dialogRef.current?.close();
}
}, [isOpen]);
return (
<dialog
ref={dialogRef}
aria-labelledby="modal-title"
onClose={onClose}
>
<div role="document">
<h2 id="modal-title">{title}</h2>
{children}
<button onClick={onClose} aria-label="閉じる">
✕
</button>
</div>
</dialog>
);
}
タブUI
function Tabs({ tabs }: { tabs: { label: string; content: ReactNode }[] }) {
const [activeIndex, setActiveIndex] = useState(0);
const handleKeyDown = (e: KeyboardEvent, index: number) => {
switch (e.key) {
case 'ArrowRight':
setActiveIndex((index + 1) % tabs.length);
break;
case 'ArrowLeft':
setActiveIndex((index - 1 + tabs.length) % tabs.length);
break;
case 'Home':
setActiveIndex(0);
break;
case 'End':
setActiveIndex(tabs.length - 1);
break;
}
};
return (
<div>
<div role="tablist" aria-label="コンテンツタブ">
{tabs.map((tab, i) => (
<button
key={i}
role="tab"
id={`tab-${i}`}
aria-selected={activeIndex === i}
aria-controls={`panel-${i}`}
tabIndex={activeIndex === i ? 0 : -1}
onClick={() => setActiveIndex(i)}
onKeyDown={(e) => handleKeyDown(e, i)}
>
{tab.label}
</button>
))}
</div>
{tabs.map((tab, i) => (
<div
key={i}
role="tabpanel"
id={`panel-${i}`}
aria-labelledby={`tab-${i}`}
hidden={activeIndex !== i}
>
{tab.content}
</div>
))}
</div>
);
}
アコーディオン
function Accordion({ items }: { items: { title: string; content: string }[] }) {
const [openIndex, setOpenIndex] = useState<number | null>(null);
return (
<div>
{items.map((item, i) => (
<div key={i}>
<h3>
<button
aria-expanded={openIndex === i}
aria-controls={`accordion-panel-${i}`}
onClick={() => setOpenIndex(openIndex === i ? null : i)}
>
{item.title}
<span aria-hidden="true">{openIndex === i ? '▲' : '▼'}</span>
</button>
</h3>
<div
id={`accordion-panel-${i}`}
role="region"
aria-labelledby={`accordion-header-${i}`}
hidden={openIndex !== i}
>
<p>{item.content}</p>
</div>
</div>
))}
</div>
);
}
フォームのアクセシビリティ
基本パターン
<!-- ✅ ラベルとinputの関連付け -->
<div>
<label for="email">メールアドレス<span aria-hidden="true">*</span></label>
<input
id="email"
type="email"
name="email"
required
aria-required="true"
aria-describedby="email-hint email-error"
aria-invalid="true"
>
<p id="email-hint" class="hint">例: user@example.com</p>
<p id="email-error" class="error" role="alert">
メールアドレスの形式が正しくありません
</p>
</div>
グループ化
<fieldset>
<legend>配送方法を選択してください</legend>
<div>
<input type="radio" id="shipping-standard" name="shipping" value="standard">
<label for="shipping-standard">通常配送(3〜5営業日)</label>
</div>
<div>
<input type="radio" id="shipping-express" name="shipping" value="express">
<label for="shipping-express">速達(翌営業日)</label>
</div>
</fieldset>
テスト自動化
axe-core + Playwright
// e2e/accessibility.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test.describe('アクセシビリティテスト', () => {
test('トップページにWCAG違反がないこと', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
.analyze();
expect(results.violations).toEqual([]);
});
test('フォームページのアクセシビリティ', async ({ page }) => {
await page.goto('/contact');
const results = await new AxeBuilder({ page })
.include('form')
.analyze();
expect(results.violations).toEqual([]);
});
});
eslint-plugin-jsx-a11y
// .eslintrc.json
{
"extends": ["plugin:jsx-a11y/recommended"],
"rules": {
"jsx-a11y/alt-text": "error",
"jsx-a11y/anchor-is-valid": "error",
"jsx-a11y/no-autofocus": "warn",
"jsx-a11y/label-has-associated-control": "error"
}
}
Storybook + a11yアドオン
// .storybook/main.ts
export default {
addons: ['@storybook/addon-a11y'],
};
チェックリスト
【基本】
☐ すべての画像にalt属性がある
☐ 見出しが順序通り(h1→h2→h3)
☐ ページにlang属性が設定されている
☐ セマンティックHTML(header/main/nav/footer)
【操作】
☐ すべての機能がキーボードで操作可能
☐ フォーカスインジケータが見える
☐ スキップリンクが実装されている
☐ モーダルにフォーカストラップがある
【フォーム】
☐ すべてのinputにlabelが関連付けられている
☐ エラーメッセージが具体的
☐ 必須項目が明示されている
【色・コントラスト】
☐ テキストのコントラスト比 4.5:1以上
☐ 大きなテキストのコントラスト比 3:1以上
☐ 色だけに依存していない
【テスト】
☐ axe-coreで自動テスト
☐ キーボードのみで全ページ操作確認
☐ スクリーンリーダーでの確認(VoiceOver/NVDA)
アクセシビリティは「対応すべき追加作業」ではなく、良いHTMLを書くことの自然な結果です。セマンティックHTMLを基本にし、WAI-ARIAで補完し、自動テストで品質を維持する——このアプローチで、すべてのユーザーに使いやすいWebを作りましょう。