"なんとなく"から抜け出す！Reactで実践するアクセシビリティ【コードつき】

# はじめに
「ウェブアクセシビリティって聞いたことはあるけど、実際に何をすればいいのか分からない」そんな方のために、React.js を使った具体的なコード例つきでアクセシビリティ対応のポイントをまとめました。
NGパターンとOKパターンを比較しながら、「どう実装すればよいか」がパッと見てわかるようにしています。実務ですぐに使えるような形式にしているので、アクセシビリティ対応の第一歩として、ぜひ活用してください。

# 対象者
- アクセシビリティ対応に興味があるが、まだ手をつけられていないフロントエンドエンジニア
- React.js でUIを実装していて、配慮すべき点を知りたい方
- アクセシビリティの実装方法を実例で理解したい人

# 今回参考にしたサイト
https://www.digital.go.jp/resources/introduction-to-web-accessibility-guidebook

※音声やアニメーションなどReact.jsのコードだけで表現しにくい項目に関しては除外してます

# 達成しないと利用者に重大な悪影響を及ぼすもの(重大)
どのようなサービスやコンテンツでも必ず達成すべき基準。実現できていないと、利用者がページから離脱したり、理解困難・誤操作・危険性が生じる可能性あり。
### 🔇 自動再生させないようにする（WCAG 2.0：1.4.2）
音声の自動再生や強制再生は避ける。音声が自動で流れる場合は 3秒以内 に収める、または 停止・ミュート機能をつける必要がある。
```✅ OK例(自動再生される動画に停止・ミュート手段がある).tsx
import { useRef } from "react";

function AccessibleVideo() {
  const videoRef = useRef(null);

  const toggleMute = () => {
    const video = videoRef.current;
    if (video) video.muted = !video.muted;
  };

  const togglePlay = () => {
    const video = videoRef.current;
    if (video) {
      video.paused ? video.play() : video.pause();
    }
  };

  return (
    <div>
      <video
        ref={videoRef}
        autoPlay
        muted
        aria-label="風景紹介ビデオ"
        width={600}
      >
        <source src="/video/scenery.mp4" type="video/mp4" />
        お使いのブラウザは video タグをサポートしていません。
      </video>
      <div>
        <button onClick={toggleMute}>🔇 ミュート</button>
        <button onClick={togglePlay}>⏸️ 再生/停止</button>
      </div>
    </div>
  );
}
```
```❌ NG例(自動再生される動画に停止・ミュート手段がない).tsx
function InaccessibleVideo() {
  return (
    <video autoPlay>
      <source src="/video/scenery.mp4" type="video/mp4" />
      お使いのブラウザは video タグをサポートしていません。
    </video>
  );
}
```

### 🚪 袋小路に陥らせないようにする（WCAG 2.0：2.1.2）
キーボードのみで操作しているユーザーが、フォーカスから抜け出せなくなるUI（例：モーダルダイアログ）を作らない。
閉じるボタンなどで キーボード操作だけでも脱出可能にすることが重要。
```✅ OK例(フォーカス制御や閉じる操作ができるモーダル).tsx
import { useRef, useEffect } from "react";

function HelpModal({ isOpen, onClose }) {
  const closeBtnRef = useRef(null);

  useEffect(() => {
    if (isOpen) {
      closeBtnRef.current?.focus(); // 初回フォーカスをボタンへ
    }
  }, [isOpen]);

  if (!isOpen) return null;

  return (
    <div
      role="dialog"
      aria-modal="true"
      aria-labelledby="helpTitle"
      className="modal"
    >
      <h2 id="helpTitle">ヘルプ</h2>
      <p>操作方法をご案内します。</p>
      <button ref={closeBtnRef} onClick={onClose}>
        閉じる
      </button>
    </div>
  );
}
```
```❌ NG例：フォーカス制御も閉じる操作もできないモーダル.tsx
function BadModal() {
  return (
    <div role="dialog" aria-modal="true">
      <h2>ヘルプ</h2>
      <p>操作方法をご案内します。</p>
      {/* ❌ フォーカス制御なし、閉じる手段なし */}
    </div>
  );
}
```

### 🔁 自動でコンテンツを切り替えるようにする（WCAG 2.0：2.2.2）
スライドショーやカルーセルなど、自動で変わるコンテンツには以下の機能が必要。

- 一時停止
- 停止
- 非表示

動き続けるコンテンツは他の操作や閲覧を妨げるため、制御手段を用意するのが必須。
```✅ OK例(自動再生するが、停止手段があるカルーセル).tsx
import { useEffect, useRef, useState } from "react";

function AccessibleCarousel() {
  const slides = ["スライド1", "スライド2", "スライド3"];
  const [current, setCurrent] = useState(0);
  const intervalRef = useRef(null);

  useEffect(() => {
    intervalRef.current = setInterval(() => {
      setCurrent((prev) => (prev + 1) % slides.length);
    }, 5000);

    return () => clearInterval(intervalRef.current);
  }, []);

  const stopCarousel = () => clearInterval(intervalRef.current);

  return (
    <div>
      <div aria-live="polite" role="region">
        <p>{slides[current]}</p>
      </div>
      <button onClick={stopCarousel}>⏸️ 一時停止</button>
    </div>
  );
}
```

```❌ NG例(自動再生するが、停止手段がないカルーセル).tsx
import { useEffect, useRef, useState } from "react";

function InaccessibleCarousel() {
  const slides = ["スライド1", "スライド2", "スライド3"];
  const [current, setCurrent] = useState(0);

  useEffect(() => {
    const interval = setInterval(() => {
      setCurrent((prev) => (prev + 1) % slides.length);
    }, 4000);

    return () => clearInterval(interval);
  }, []);

  return (
    <div>
      <div>
        <p>{slides[current]}</p>
      </div>
      {/* ❌ 一時停止ボタンなし */}
    </div>
  );
}
```

# 必ず達成しなければならないもの（必須）
「非干渉」ほどではないが、満たさないと操作不能や情報伝達の欠落が起きる重要な基準。確実に対応する必要があります。

### 🖼️ 画像に代替テキストを付ける（WCAG 2.0：1.1.1）
- ロゴ、写真、図表など、画像が示している情報には代替テキスト（alt）をつける
- 画像の内容を簡潔に伝えるため、文脈に合わせて記述を調整する
- デコレーション目的の画像は alt="" にする

```✅ OK例.tsx
function ImageExamples() {
  return (
    <div>
      {/* 意味のある画像 */}
      <img src="/icons/mail.png" alt="メールを送信" />

      {/* グラフ画像：周囲に補足あり */}
      <img src="/graphs/sales.png" alt="2024年売上の推移（詳細は下記テーブル参照）" />

      {/* 装飾目的の画像 */}
      <img src="/decor/border.svg" alt="" aria-hidden="true" />
    </div>
  );
}
```

### ⌨️ キーボード操作だけですべての機能にアクセスできるようにする(WCAG 2.0：2.1.1 / 2.4.3 / 3.2.1 など）
Tabキー操作で、すべてのインターフェース要素にアクセスさせる。
フォーカス時の表示、入力確定・キャンセル動作なども意図通りに制御する。
```✅ OK例(キーボード操作でサジェスト候補にアクセスできる).tsx
import { useState } from "react";

function SearchInput() {
  const [query, setQuery] = useState("");
  const suggestions = ["Tシャツ", "シャツ 綿100%", "長袖Tシャツ"];

  return (
    <div>
      <label htmlFor="search">商品検索</label>
      <input
        id="search"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        aria-autocomplete="list"
        aria-controls="suggestion-list"
      />
      {query && (
        <ul id="suggestion-list">
          {suggestions.map((item, i) => (
            <li key={i}>
              <button onClick={() => setQuery(item)}>{item}</button>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}
```

```❌ NG例(キーボード操作でサジェスト候補にアクセスできない).tsx
import { useState } from "react";

function InaccessibleSearchInput() {
  const [query, setQuery] = useState("");
  const suggestions = ["Tシャツ", "シャツ 綿100%", "長袖Tシャツ"];

  return (
    <div>
      <label htmlFor="search">商品検索</label>
      <input
        id="search"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
      />
      {query && (
        <ul>
          {suggestions.map((item, i) => (
            <li key={i}>{item}</li> // ❌ クリックもできないただのテキスト
          ))}
        </ul>
      )}
    </div>
  );
}

```

### 🎨 色・文字の強調だけで情報を伝えない（WCAG 2.0：1.3.1 / 1.3.3 / 1.4.1）
赤字・太字・下線・拡大表示などの視覚的な強調だけでは、情報が伝わらない人もいる。
必須項目や注意点は、文章やマーク等で明示することが必要。

:::note info
.sr-only は「視覚的に非表示だが[スクリーンリーダー](https://developer.mozilla.org/ja/docs/Glossary/Screen_reader)には読まれる」クラスです。Tailwind CSSなら sr-only クラスが用意されています。
:::

```✅ OK例(必須が視覚的に非表示だがスクリーンリーダーには読まれる).tsx
function RequiredField() {
  return (
    <div>
      <label htmlFor="address">
        住所 <span style={{ color: "red" }} aria-hidden="true">※</span>
        <span className="sr-only">(必須)</span>
      </label>
      <input id="address" required aria-required="true" />
    </div>
  );
}
```


```❌ NG例(色だけで「必須」であることを示している).tsx
function RequiredFieldBad() {
  return (
    <div>
      <label htmlFor="address">
        住所 <span style={{ color: "red" }}>※</span>
      </label>
      <input id="address" />
    </div>
  );
}
```

### 📖 スクリーンリーダーで意味が通じる順序にする（WCAG 2.0：1.3.1 / 1.3.2 / 2.4.3）
スクリーンリーダーはDOMツリー順（ソース順）で読み上げるため、視覚の配置に頼らず、論理的な順番で記述することが重要です。

```✅ OK例(ラベルとフィールドが正しくペアになっている).tsx
function EventForm() {
  return (
    <form>
      <div>
        <label htmlFor="date">日時</label>
        <select id="date" name="date">
          <option value="today">にちようび</option>
        </select>
      </div>

      <div>
        <label htmlFor="place">場所</label>
        <select id="place" name="place">
          <option value="park">ぱしょ</option>
        </select>
      </div>
    </form>
  );
}
```

```❌ NG例（ラベルとフィールドが離れ、読み上げ順がズレる）.tsx
function BadLayout() {
  return (
    <form>
      <div style={{ display: "flex" }}>
        <p>日時：</p>
        <span>にちようび</span>
        <p>場所：</p>
        <span>ぱしょ</span>
      </div>
    </form>
  );
}
```
```✅ OK例（文脈の直後に置く）.tsx
function ConsentForm() {
  return (
    <form>
      <fieldset>
        <legend>同意事項</legend>
        <p>ご利用規約に同意のうえ、申し込んでください。</p>
        <label>
          <input type="checkbox" required /> 利用規約に同意する
        </label>
      </fieldset>
      <button type="submit">同意して申し込む</button>
    </form>
  );
}
```

```❌ NG例(同意ボタンが文脈と離れ、意味が伝わりにくい).tsx
function BadConsentForm() {
  return (
    <form>
      {/* ❌ 文脈より先にボタンがある */}
      <button type="submit">同意して申し込む</button>

      <fieldset>
        <legend>同意事項</legend>
        <p>ご利用規約に同意のうえ、申し込んでください。</p>
        <label>
          <input type="checkbox" required /> 利用規約に同意する
        </label>
      </fieldset>
    </form>
  );
}
```
### 🧩 見出し要素だけで、セクションやブロックに含まれる要素を表現する（WCAG 2.0：1.3.1 / 2.4.6 / 2.4.10）
見出しは構造を正しく表現するために重要で、文書の意味をスクリーンリーダー等でも正しく伝えるためには適切なレベル（h1〜h6）を使うことが必要。

- 「リスト」など汎用的すぎる見出しは意味を成さない
- 内容に応じて階層的なレベル設定（h2→h3など）を行う
- 強調のためだけに見出しタグを使わない

```✅ OK例(具体的な見出し).tsx
function RecipeSection() {
  return (
    <section>
      <h2>お菓子のレシピリスト</h2>
      <ul>
        <li>クッキー</li>
        <li>ミートパイ</li>
        <li>バナナマフィン</li>
        <li>チーズケーキ</li>
      </ul>
    </section>
  );
}
```
```❌ NG例(「リスト」だけでは内容が不明瞭).tsx
function InaccessibleList() {
  return (
    <section>
      <h2>リスト</h2>
      <ul>
        <li>クッキー</li>
        <li>ミートパイ</li>
        <li>バナナマフィン</li>
        <li>チーズケーキ</li>
      </ul>
    </section>
  );
}
```
### 🎯 文字と背景の間に十分なコントラスト比を保つ（WCAG 2.0：1.4.3）
視認性を保つため、文字と背景色のコントラスト比を十分に確保する。

- 小さな文字では 4.5:1以上
- 大きな文字（18pt以上 or 14pt太字）では 3:1以上



```✅ OK例(コントラストが適切).tsx
function HighContrastText() {
  return (
    <div style={{ backgroundColor: "#333", color: "#fff", padding: "1rem" }}>
      コントラストが適切なテキストです
    </div>
  );
}
```
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/840696/38ad2eb4-6a0b-42ae-a6ed-9178efd560c9.png" width=400px>

```❌ NG例(コントラストが低すぎる).tsx
function LowContrastText() {
  return (
    <div style={{ backgroundColor: "#ffffff", color: "#aaaaaa", padding: "1rem" }}>
      コントラストが低すぎるテキストです
    </div>
  );
}
```
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/840696/9b2e164d-c99f-4819-9068-e56f9f37dfc1.png" width=400px>

### 🔍 テキストの拡大縮小をしても情報が読めるようにする（WCAG 2.0：1.4.4）

ブラウザの文字拡大機能で200%まで拡大してもレイアウトが崩れず、内容が読めるように設計する。

- user-scalable=no は避ける（拡大を妨げる）
- レイアウトは柔軟に可変対応（rem / em 単位推奨）

```✅ OK例(ブラウザ拡大しても崩れない).tsx
function ResponsiveText() {
  return (
    <div style={{ fontSize: "1rem", lineHeight: 1.5 }}>
      このテキストはユーザーがブラウザ拡大しても崩れません。
    </div>
  );
}
```
```❌ NG例((ブラウザ拡大したら崩れる).tsx
function FixedText() {
  return (
    <>
      <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
      <div style={{ fontSize: "12px" }}>
        ❌ スマートフォンで拡大できず、読みづらい
      </div>
    </>
  );
}
```

### 🧾 ページの内容を示すタイトルを適切に表現する（WCAG 2.0：2.4.2）
- タイトルはそのページの内容を表すように設定する
- 全ページで同じタイトルを使い回すのはNG
- ページタイトルとページ見出し（h1）が一致していると、スクリーンリーダー利用者にも安心感を与える


```✅ OK例(ページ内容を判別しやすい).tsx
import { Helmet } from "react-helmet";

function RecipePage() {
  return (
    <>
      <Helmet>
        <title>トマトパスタのレシピ</title>
      </Helmet>
      <main>
        <h1>トマトパスタのレシピ</h1>
        <p>美味しいトマトソースの作り方を紹介します。</p>
      </main>
    </>
  );
}
```
```❌ NG例(同じタイトルが複数ページ).tsx
import { Helmet } from "react-helmet";

function RecipePage() {
  return (
    <>
      <Helmet>
        <title>レシピ情報</title> {/* ❌ 複数ページで同一タイトル */}
      </Helmet>
      <main>
        <h1>トマトパスタのレシピ</h1>
      </main>
    </>
  );
}
```

### 🔗 リンクを適切に表現する（WCAG 2.0：2.4.4）
- 「詳しくはこちら」「もっと見る」などの曖昧なリンクテキストは避ける
- スクリーンリーダーではリンクテキストのみが一覧表示されることが多いため、リンク先の内容が伝わる表現にする

```✅ OK例(具体的なリンクテキスト).tsx
function TrashGuideLinks() {
  return (
    <div>
      <ul>
        <li><a href="/garbage/bulky">粗大ごみの出し方の詳細</a></li>
        <li><a href="/garbage/recyclable">資源ごみの出し方の詳細</a></li>
      </ul>
    </div>
  );
}
```
```❌ NG例(曖昧なリンクテキスト).tsx
function BadLinks() {
  return (
    <div>
      <ul>
        <li><a href="/garbage/bulky">詳しくはこちら</a></li>
        <li><a href="/garbage/recyclable">詳しくはこちら</a></li>
      </ul>
    </div>
  );
}
```

### 🧭 ナビゲーションに一貫性をもたせる（WCAG 2.0：3.2.3）
- サイトのグローバルナビゲーションは、すべてのページで同じ順序・構造にする
- 項目の並びやラベル、配置位置が変わると、利用者が混乱する

```トップページ例.tsx
function Navigation() {
  return (
    <nav aria-label="メインナビゲーション">
      <ul>
        <li><a href="/top">トップ</a></li>
        <li><a href="/about">施設紹介</a></li>
        <li><a href="/price">料金</a></li>
        <li><a href="/contact">お問い合わせ</a></li>
      </ul>
    </nav>
  );
}
```
```❌ 別ページでナビゲーション順序・構造が変わってはいけない.tsx
function InconsistentNav() {
  return (
    <nav>
      {/* ❌ トップページと別ページで項目・順番・表現が不一致 */}
      <ul>
        <li><a href="/help">ヘルプ</a></li>
        <li><a href="/about">サービス紹介</a></li>
        <li><a href="/home">ホーム</a></li>
      </ul>
    </nav>
  );
}
```

### 🧩 同じ機能には、同じラベルや説明をつける（WCAG 2.0：3.2.4）
- 同じ意味・同じ動作をするUIには、常に同じラベル・アイコン・説明を使う
- 見た目や文言がページごとに違うとユーザーが混乱する

```✅ OK例(ラベルや見た目が一致).tsx
function ReceiptButton() {
  return (
    <button aria-label="領収書をダウンロード">領収書をダウンロード</button>
  );
}
```
```❌ NG例(見た目や文言の差異がある).tsx
function InconsistentReceiptButton({ context }: { context: string }) {
  return (
    <>
      {context === "履歴" ? (
        <button aria-label="明細書を出力">明細書を出力</button> // ❌ 意味は同じだが表現が異なる
      ) : (
        <button aria-label="パソコンに保存">パソコンに保存</button> // ❌ ラベルの一貫性なし
      )}
    </>
  );
}
```

# 状況に応じて確認すべきこと（個別対応）
すべてのウェブサイトや情報システムに一律に求められるわけではないが、対象や利用状況によっては配慮が必要となるアクセシビリティ基準のことです。

### 📝 入力フォームを様々な使い方でも使えるようにする（WCAG 2.0：1.3.1 / 3.3.1 / 3.3.2）
- ラベルは必ず明示的に付ける
- 入力制限（例：全角・半角など）は明示
- エラー発生時は箇所と内容を具体的に表示
- スクリーンリーダーでも読まれるようにする
- 入力内容確認・取消などの配慮

```✅ OK例(ラベルや入力制限が明確).tsx
function InquiryForm() {
  return (
    <form>
      <label htmlFor="inquiryNumber">お問合せ番号（半角）</label>
      <input id="inquiryNumber" type="text" pattern="\d*" />

      <label htmlFor="inquiryContent">
        お問合せ内容 <span aria-hidden="true">(必須)</span>
      </label>
      <textarea
        id="inquiryContent"
        required
        maxLength={500}
        aria-describedby="contentLimit"
      />
      <p id="contentLimit">0/500</p>
    </form>
  );
}
```

```❌ NG例(ラベルや入力制限が不明確).tsx
<input type="text" placeholder="お問合せ番号を入力" />
```

### 🌀 コンテンツの変化がスクリーンリーダーにも分かるようにする（WCAG 2.0 達成基準：2.2.1、3.2.2、3.2.5）
- ページ内の 一部の更新や状態変化（例：ダイアログ、トースト、チェック状態など）も、スクリーンリーダーで正しく伝える必要がある
- フォーカスの適切な移動や、ARIA属性の活用が重要
- モーダルを開いたらフォーカスをモーダル内に移動し、閉じたら元に戻すのが基本


**✅ OKのポイント**
- `role="dialog"` や `aria-modal="true"` でモーダルを支援技術に明示
- `focus()` によるフォーカス誘導
- 終了後にフォーカスを戻す
```✅ OK例.tsx

import { useEffect, useRef, useState } from "react";

function AccessibleModal({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
  const cancelButtonRef = useRef<HTMLButtonElement>(null);

  useEffect(() => {
    if (isOpen) {
      cancelButtonRef.current?.focus(); // モーダル内に初期フォーカス
    }
  }, [isOpen]);

  if (!isOpen) return null;

  return (
    <div
      role="dialog"
      aria-modal="true"
      aria-labelledby="accessible-dialog-title"
      className="modal"
    >
      <h2 id="accessible-dialog-title">本当にメッセージを削除しますか？</h2>
      <button ref={cancelButtonRef} onClick={onClose}>キャンセル</button>
      <button onClick={() => { alert("削除しました"); onClose(); }}>削除</button>
    </div>
  );
}

export default function AccessibleModalExample() {
  const deleteBtnRef = useRef<HTMLButtonElement>(null);
  const [isOpen, setIsOpen] = useState(false);

  const handleClose = () => {
    setIsOpen(false);
    deleteBtnRef.current?.focus(); // 元の操作位置に戻す
  };

  return (
    <div style={{ padding: "2rem" }}>
      <h1>アクセシブルなモーダル</h1>
      <button ref={deleteBtnRef} onClick={() => setIsOpen(true)}>
        削除
      </button>
      <AccessibleModal isOpen={isOpen} onClose={handleClose} />
    </div>
  );
}
```

**❌ NGの問題点**
- モーダルがスクリーンリーダーに認識されない
- フォーカス制御がなく、キーボード操作での利用が困難

```❌ NG例.tsx
import { useState } from "react";

function InaccessibleModal({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
  if (!isOpen) return null;

  return (
    <div className="modal">
      <h2>削除しますか？</h2>
      {/* ❌ アクセシビリティ属性なし、フォーカス制御なし */}
      <button onClick={onClose}>キャンセル</button>
      <button onClick={() => { alert("削除しました"); onClose(); }}>削除</button>
    </div>
  );
}

export default function InaccessibleModalExample() {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div style={{ padding: "2rem" }}>
      <h1>アクセシビリティ非対応モーダル</h1>
      <button onClick={() => setIsOpen(true)}>削除</button>
      <InaccessibleModal isOpen={isOpen} onClose={() => setIsOpen(false)} />
    </div>
  );
}
```

# 導入に慎重な検討が必要（非推奨）
アクセシビリティを向上させるつもりでも、実際には支援技術と衝突したり、逆効果になるケースがあります。以下のような技術の導入は慎重に検討してください。

### 文字サイズの変更、読み上げプラグインの利用は非推奨
ブラウザやOSの機能で対応可能な文字サイズ変更・読み上げなどを、サイト側で独自実装するのは避ける。支援技術ユーザーが自分の使い慣れた設定を使えなくなる可能性があります。

**✅ OKな対応の考え方**
- CSSは rem や em を使って拡大縮小に柔軟に対応
- 読み上げは aria 属性や HTML 構造による補完で対応
- ブラウザや支援技術が活かせる構造・設計にする

```❌ NG例(独自な文字サイズ変更UI).tsx
function FontSizeChanger() {
  const changeFontSize = (size) => {
    document.body.style.fontSize = size;
  };

  return (
    <div>
      <button onClick={() => changeFontSize("14px")}>文字サイズ 小</button>
      <button onClick={() => changeFontSize("18px")}>文字サイズ 中</button>
      <button onClick={() => changeFontSize("22px")}>文字サイズ 大</button>
    </div>
  );
}
```

# おわりに
https://www.digital.go.jp/resources/introduction-to-web-accessibility-guidebook

↑デジタル庁のウェブアクセシビリティのサイトが本当にわかりやすかったです。
ガイドブック(PDF)には画像付きで解説されているので是非見て見てください。

最後までお読みいただき、ありがとうございました！