← ブログ一覧

Web アクセシビリティ(a11y)対応の実務チェックリスト

WCAG 2.2 準拠を目指す実装手順を解説。セマンティック HTML・ARIA・キーボード操作・スクリーンリーダー対応まで、受託開発で即使えるアクセシビリティ改善ガイド。

#Web#アクセシビリティ#React#Next.js
Web アクセシビリティ(a11y)対応の実務チェックリスト

Web アクセシビリティ(a11y)対応の実務チェックリスト

アクセシビリティ(a11y)対応は、すべてのユーザーが Web アプリを利用できるようにするための重要な要素です。しかし「どこから手を付ければいいか分からない」「WCAG 2.2 の膨大な基準をどう実装に落とし込むか」という課題に直面する開発者も少なくありません。

本記事では、受託開発や自社プロダクトの現場で今日から実装できるアクセシビリティ対応の実務手順を解説します。セマンティック HTML、ARIA 属性、キーボード操作、スクリーンリーダー対応まで、優先度付きで整理しました。


1. アクセシビリティ対応が必要な理由

ビジネス価値

| 観点 | 詳細 | |------|------| | 法的リスク | 米国 ADA、欧州 EAA など法的義務化が進行中 | | ユーザー拡大 | 視覚・聴覚・運動機能障害のある約 15% の人口をカバー | | SEO 向上 | セマンティック HTML は検索エンジン評価も向上 | | 保守性向上 | 明確な構造化は開発チーム全体のコード理解を促進 |

WCAG 2.2 の基準レベル

  • Level A(必須): 最低限のアクセシビリティ
  • Level AA(推奨): 実務で目指すべき基準、多くの法規制の要求レベル
  • Level AAA(理想): 高度な対応、すべてのケースで達成は困難

本記事では Level AA を実現する実装方法を中心に解説します。


2. 優先度別チェックリスト

2-1. 優先度:高(Level A 必須項目)

セマンティック HTML

// ❌NG: div スープ
<div onClick={handleClick}>送信</div>
<div>
  <div>見出し</div>
  <div>本文</div>
</div>

// ✅OK: 正しいセマンティック要素
<button onClick={handleClick}>送信</button>
<article>
  <h2>見出し</h2>
  <p>本文</p>
</article>

チェック項目:

  • [ ] <button> でボタン、<a> でリンクを使用
  • [ ] 見出しは <h1><h6> を階層的に使用
  • [ ] リストは <ul> / <ol> / <dl> を使用
  • [ ] フォームには <label> を必ず関連付け

画像の代替テキスト

// ❌NG: alt なし or 無意味
<img src="/hero.jpg" />
<img src="/icon.svg" alt="画像" />

// ✅OK: 文脈に応じた適切な alt
<img src="/hero.jpg" alt="新商品発表会の様子" />
<img src="/icon.svg" alt="" role="presentation" /> {/* 装飾画像 */}

判断フロー:

  1. 情報を含む画像: 内容を説明する alt を記述
  2. 装飾画像: alt="" + role="presentation"
  3. リンク内画像: リンク先を示す alt を記述

キーボード操作可能性

// ❌NG: onClick のみ
<div onClick={handleClick}>クリック</div>

// ✅OK: キーボードイベントも対応
<div
  role="button"
  tabIndex={0}
  onClick={handleClick}
  onKeyDown={(e) => {
    if (e.key === 'Enter' || e.key === ' ') {
      handleClick();
    }
  }}
>
  クリック
</div>

// ✅ Better: ネイティブ要素を使う
<button onClick={handleClick}>クリック</button>

チェック項目:

  • [ ] すべてのインタラクティブ要素に Tab でフォーカス可能
  • [ ] Enter / Space キーで操作可能
  • [ ] tabindex="-1" は動的フォーカス移動のみに使用
  • [ ] tabindex > 0 は使用禁止(フォーカス順序を破壊)

2-2. 優先度:中(Level AA 推奨項目)

色のコントラスト比

WCAG AA 基準:

| 要素 | 最小コントラスト比 | |------|--------------------| | 通常テキスト | 4.5:1 | | 大きいテキスト(18pt 以上) | 3:1 | | UI コンポーネント(ボタン枠など) | 3:1 |

チェック方法:

# Chrome DevTools の Lighthouse
# "Accessibility" カテゴリで自動チェック

# または専用ツール
npm install -D @axe-core/cli
npx axe https://your-site.com --rules color-contrast

修正例:

/* ❌ NG: コントラスト比 2.8:1 */
.button {
  background: #4a90e2;
  color: #ffffff;
}

/* ✅ OK: コントラスト比 4.6:1 */
.button {
  background: #0066cc;
  color: #ffffff;
}

フォーカスの視覚的表示

/* ❌ NG: outline を消すだけ */
button:focus {
  outline: none;
}

/* ✅ OK: 代替の視覚表示を提供 */
button:focus-visible {
  outline: 3px solid #0066cc;
  outline-offset: 2px;
}

/* より実務的: CSS 変数で統一 */
:root {
  --focus-ring: 0 0 0 3px rgba(0, 102, 204, 0.5);
}

button:focus-visible,
input:focus-visible {
  box-shadow: var(--focus-ring);
}

ランドマーク(Landmark)の設定

// ✅ セマンティック HTML でランドマーク
export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <div>
      <header>
        <nav aria-label="主要ナビゲーション">
          {/* グローバルナビ */}
        </nav>
      </header>
      
      <main>{children}</main>
      
      <aside aria-label="関連リンク">
        {/* サイドバー */}
      </aside>
      
      <footer>
        <nav aria-label="フッターナビゲーション">
          {/* フッターリンク */}
        </nav>
      </footer>
    </div>
  );
}

主要ランドマーク:

| 要素 | 役割 | 使用場所 | |------|------|----------| | <header> | バナー | ページ上部 | | <nav> | ナビゲーション | メニュー | | <main> | メインコンテンツ | 1ページに1つ | | <aside> | 補足情報 | サイドバー | | <footer> | フッター | ページ下部 | | <section> | セクション | 見出しを伴う区分 |


3. ARIA 属性の実践的な使い方

3-1. ARIA を使うべき場面・使うべきでない場面

原則: 「No ARIA is better than bad ARIA」

// ❌ NG: 不要な ARIA
<button aria-label="送信">送信</button> {/* ボタンテキストで十分 */}

// ✅ OK: ARIA が必要な場面
<button aria-label="メニューを開く">
  <MenuIcon /> {/* アイコンのみ */}
</button>

3-2. よく使う ARIA パターン

モーダルダイアログ

import { useEffect, useRef } from 'react';

interface ModalProps {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  children: React.ReactNode;
}

export function Modal({ isOpen, onClose, title, children }: ModalProps) {
  const dialogRef = useRef<HTMLDivElement>(null);
  const previousFocus = useRef<HTMLElement | null>(null);

  useEffect(() => {
    if (isOpen) {
      // モーダルを開いたときの元フォーカス位置を記憶
      previousFocus.current = document.activeElement as HTMLElement;
      // モーダル内にフォーカスを移動
      dialogRef.current?.focus();
    } else {
      // モーダルを閉じたら元の位置に戻す
      previousFocus.current?.focus();
    }
  }, [isOpen]);

  if (!isOpen) return null;

  return (
    <div
      role="dialog"
      aria-modal="true"
      aria-labelledby="dialog-title"
      ref={dialogRef}
      tabIndex={-1}
      onKeyDown={(e) => {
        if (e.key === 'Escape') onClose();
      }}
    >
      <h2 id="dialog-title">{title}</h2>
      {children}
      <button onClick={onClose}>閉じる</button>
    </div>
  );
}

タブ UI

import { useState } from 'react';

interface Tab {
  id: string;
  label: string;
  content: React.ReactNode;
}

export function Tabs({ tabs }: { tabs: Tab[] }) {
  const [activeId, setActiveId] = useState(tabs[0].id);

  return (
    <div>
      <div role="tablist" aria-label="コンテンツタブ">
        {tabs.map((tab) => (
          <button
            key={tab.id}
            role="tab"
            aria-selected={activeId === tab.id}
            aria-controls={`panel-${tab.id}`}
            id={`tab-${tab.id}`}
            onClick={() => setActiveId(tab.id)}
            onKeyDown={(e) => {
              const currentIndex = tabs.findIndex((t) => t.id === activeId);
              if (e.key === 'ArrowRight') {
                const next = tabs[(currentIndex + 1) % tabs.length];
                setActiveId(next.id);
              } else if (e.key === 'ArrowLeft') {
                const prev = tabs[(currentIndex - 1 + tabs.length) % tabs.length];
                setActiveId(prev.id);
              }
            }}
          >
            {tab.label}
          </button>
        ))}
      </div>

      {tabs.map((tab) => (
        <div
          key={tab.id}
          role="tabpanel"
          id={`panel-${tab.id}`}
          aria-labelledby={`tab-${tab.id}`}
          hidden={activeId !== tab.id}
        >
          {tab.content}
        </div>
      ))}
    </div>
  );
}

ローディング状態

// ❌ NG: 視覚表示のみ
{isLoading && <div className="spinner" />}

// ✅ OK: スクリーンリーダーにも通知
{isLoading && (
  <div role="status" aria-live="polite" aria-label="読み込み中">
    <div className="spinner" aria-hidden="true" />
    <span className="sr-only">読み込み中...</span>
  </div>
)}

aria-live の使い分け:

| 値 | 用途 | 例 | |----|------|----| | polite | 重要だが緊急でない通知 | 検索結果の更新 | | assertive | 即座に伝えるべき通知 | エラーメッセージ | | off | 自動通知しない(デフォルト) | 静的コンテンツ |


4. フォームのアクセシビリティ

4-1. label の正しい関連付け

// ❌ NG: label がない
<input type="text" placeholder="メールアドレス" />

// ✅ OK: 明示的な関連付け
<label htmlFor="email">メールアドレス</label>
<input id="email" type="email" />

// ✅ OK: 暗黙的な関連付け
<label>
  メールアドレス
  <input type="email" />
</label>

4-2. エラーメッセージの表示

import { useState } from 'react';

export function LoginForm() {
  const [email, setEmail] = useState('');
  const [error, setError] = useState('');

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (!email.includes('@')) {
      setError('有効なメールアドレスを入力してください');
      return;
    }
    // ...
  };

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="email">メールアドレス</label>
      <input
        id="email"
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        aria-invalid={!!error}
        aria-describedby={error ? 'email-error' : undefined}
      />
      {error && (
        <div id="email-error" role="alert" aria-live="assertive">
          {error}
        </div>
      )}
      <button type="submit">ログイン</button>
    </form>
  );
}

4-3. 必須項目の明示

// ❌ NG: * だけで判断を求める
<label>メールアドレス *</label>
<input type="email" />

// ✅ OK: required 属性 + 明示的な説明
<label htmlFor="email">
  メールアドレス <span aria-label="必須">*</span>
</label>
<input id="email" type="email" required />

// ✅ Better: aria-required でも明示
<label htmlFor="email">メールアドレス(必須)</label>
<input id="email" type="email" required aria-required="true" />

5. スクリーンリーダー対応

5-1. テスト環境の準備

| OS | スクリーンリーダー | ブラウザ | 無料 | |----|-------------------|----------|------| | Windows | NVDA | Firefox / Chrome | ✅ | | Windows | JAWS | Chrome / Edge | ❌ 有料 | | macOS | VoiceOver | Safari | ✅ | | iOS | VoiceOver | Safari | ✅ | | Android | TalkBack | Chrome | ✅ |

NVDA のインストール:

# Windows: 公式サイトからダウンロード
# https://www.nvaccess.org/download/

# 基本操作
# - NVDA + Q: 終了
# - Insert + ↓: 読み上げ開始
# - Tab: 次の要素へ移動
# - Insert + F7: 要素リスト表示

VoiceOver のショートカット(macOS):

Cmd + F5: VoiceOver の起動/終了
VO + A: 読み上げ開始/停止(VO = Ctrl + Option)
VO + →: 次の項目へ
VO + U: ローター(見出し・ランドマーク一覧)

5-2. 非表示コンテンツの実装

/* ✅ スクリーンリーダーのみに読み上げさせる */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

/* ❌ display: none は読み上げられない */
.hidden {
  display: none; /* NG */
}

使用例:

<button>
  <TrashIcon aria-hidden="true" />
  <span className="sr-only">削除</span>
</button>

6. 自動テストとチェックツール

6-1. eslint-plugin-jsx-a11y の導入

npm install -D 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/aria-props": "error",
    "jsx-a11y/aria-proptypes": "error",
    "jsx-a11y/aria-unsupported-elements": "error",
    "jsx-a11y/click-events-have-key-events": "error",
    "jsx-a11y/heading-has-content": "error",
    "jsx-a11y/label-has-associated-control": "error"
  }
}

6-2. axe-core による自動テスト

npm install -D @axe-core/playwright
// tests/accessibility.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test.describe('アクセシビリティテスト', () => {
  test('トップページに重大な問題がない', async ({ page }) => {
    await page.goto('http://localhost:3000');

    const results = await new AxeBuilder({ page })
      .withTags(['wcag2a', 'wcag2aa']) // WCAG AA レベルをテスト
      .analyze();

    expect(results.violations).toEqual([]);
  });

  test('特定要素のみをテスト', async ({ page }) => {
    await page.goto('http://localhost:3000/form');

    const results = await new AxeBuilder({ page })
      .include('#login-form') // 対象を限定
      .analyze();

    expect(results.violations).toEqual([]);
  });
});

6-3. CI での自動チェック

# .github/workflows/a11y.yml
name: Accessibility Check

on: [push, pull_request]

jobs:
  a11y:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      
      - run: npm ci
      - run: npm run build
      - run: npm run start &
      - run: npx wait-on http://localhost:3000
      
      # Lighthouse CI によるチェック
      - run: npm install -g @lhci/cli
      - run: lhci autorun --collect.url=http://localhost:3000
      
      # または axe CLI
      - run: npx @axe-core/cli http://localhost:3000

7. 段階的導入戦略

フェーズ 1: 基礎対応(1〜2週間)

| 項目 | 作業内容 | 担当 | |------|----------|------| | HTML セマンティクス | div/span を適切な要素に置換 | フロントエンド | | 画像 alt | 全画像に alt 属性追加 | フロントエンド | | フォーム label | 全入力要素に label 関連付け | フロントエンド | | eslint 導入 | jsx-a11y プラグイン設定 | リード |

フェーズ 2: インタラクション対応(2〜3週間)

| 項目 | 作業内容 | 担当 | |------|----------|------| | キーボード操作 | Tab/Enter/Escape 対応 | フロントエンド | | フォーカス表示 | focus-visible スタイル統一 | デザイナー + FE | | ARIA 基本 | モーダル・タブに ARIA 追加 | フロントエンド | | 自動テスト | Playwright + axe-core 導入 | QA |

フェーズ 3: 品質向上(継続的)

| 項目 | 作業内容 | 担当 | |------|----------|------| | コントラスト比 | デザインシステムに反映 | デザイナー | | スクリーンリーダー | 主要フローを手動テスト | QA + FE | | ドキュメント | a11y ガイドライン作成 | リード | | 定期監査 | 四半期ごとに Lighthouse チェック | QA |


8. よくある質問

Q1. すでにリリース済みのプロダクトにどう適用すべきか?

A. 以下の優先順位で段階的に対応します:

  1. 重大な問題(Level A 違反): 画像 alt なし、キーボード操作不可
  2. よく使う画面から対応: ログイン、ダッシュボード、主要フロー
  3. 新規開発画面では最初から準拠を義務化
  4. 定期的なリファクタリングで既存画面を改善

Q2. デザイナーとの協働で注意すべき点は?

A. 以下を設計段階で共有します:

  • コントラスト比の基準(4.5:1 以上)
  • フォーカス状態のデザイン(outline / box-shadow)
  • アイコンのみのボタンには label が必要
  • 色だけで情報を伝えない(赤文字+「エラー」テキスト)

Q3. 予算・期間が限られている場合の最低限の対応は?

A. 以下に集中します:

  1. セマンティック HTML(<button>, <label>, <h1><h6>
  2. 画像の alt 属性
  3. キーボード操作(Tab でフォーカス可能)
  4. eslint-plugin-jsx-a11y の導入

これだけで Level A の 80% はカバーできます。


まとめ

アクセシビリティ対応は「特別な対応」ではなく、すべてのユーザーに使いやすいプロダクトを作るための基本です。本記事で紹介した内容を実践することで:

  • ✅ WCAG 2.2 Level AA の主要項目をクリア
  • ✅ 法的リスクを低減し、ユーザー層を拡大
  • ✅ SEO・保守性の副次的な向上
  • ✅ 自動テストで継続的な品質維持

が実現できます。

今日から eslint-plugin-jsx-a11y の導入セマンティック HTML への置き換えから始めてみてください。


アクセシビリティ対応の支援が必要な方へ

Yureate では、既存プロダクトの a11y 監査・改善実装・チーム向けトレーニングを提供しています。お気軽に お問い合わせ ください。

この内容について相談する他の記事を見る