チームでアクセシビリティに取り組もうとすると、「気をつけて実装しよう」だけでは個人差が出ます。
ドキュメントに書いても読まれないし、レビューで指摘しても抜けが残るなどアクセシビリティ対応への課題を抱えてる方も多いのではないでしょうか。
そこで本記事では、個人の意識に頼らず、TypeScript の型レベルで誤用を防ぐ 仕組みづくりの考え方を、アイコン実装を題材に紹介します。
今後みなさんがチームでアクセシビリティに取り組むときのヒントにしていただけたら幸いです。
また、アクセシビリティへの取り組みをチームで推進していきたいと考えている方は下記の記事も参考にいただければ幸いです。
アクセシビリティとスクリーンリーダーの基本
本題の前に、この記事が前提とするアクセシビリティの仕組みを簡単に整理します。
ウェブのアクセシビリティ対応で重要な要素の1つとして、視覚障害のあるユーザーが画面の内容を読み上げて理解するための スクリーンリーダー への対応があります。
スクリーンリーダーは HTML を解析して読み上げを行いますが、画像やアイコンは見ただけでは意味が分からないため、開発者が aria-* 属性を使って 意味を補足してあげる必要があります。
// 例: ボタンの中のアイコン
<button aria-label="保存する">
<SaveIcon aria-hidden="true" />
</button>
代表的な属性はこの 2 つです。
-
aria-label: 要素に「これは何か」というラベルを与える -
aria-hidden="true": スクリーンリーダーから要素を隠す
この 2 つをアイコンにどう使い分けるか、というのが今回のテーマです。
今回題材にしたい実装
アイコン付きのボタンを実装するとき、こんな書き方をしたことはないでしょうか。
// パターン A: アイコンだけのボタン
<button>
<SaveIcon />
</button>
// パターン B: ラベルも付けたボタン
<button aria-label="保存する">
<SaveIcon aria-label="保存アイコン" />
</button>
// パターン C: ステータス表示のアイコン
<CheckIcon />
「とりあえずアイコンを置いておけば見た目は伝わるし、aria-label も親切心で付けておこう」
という気持ちで書くと上記のようなコードになります。
実はこの実装、スクリーンリーダーにとっては余計な気遣いとなります…。
では、どう書き換えるとアクセシブルになるのかを整理していきます。
この記事で伝えたいこと
アクセシビリティを 個人の意識ではなく、仕組みとして守る ためのアプローチを紹介します。
題材はアイコン実装で、「装飾的なもの」と「情報を持つもの」の 2 種類に明確に分けて実装し、その区別を TypeScript の型で強制する という考え方です。
これだけで、実装者の意識や経験に頼らず、コンパイル時点で誤用を防げるようになります。
新しいメンバーが入っても、ドキュメントを読まずに正しく書ける ことが、チームでアクセシビリティを長く守るコツだと考えています。
3つの改善ポイント
冒頭のコードには、3 つの改善ポイントがあります。
改善① スクリーンリーダーが「ボタン ボタン」と読み上げてしまう
パターン A のように aria-label が無いと、スクリーンリーダーは「ボタン」としか読み上げません。
ユーザーは何のボタンか判断できず、押すのをためらってしまいます。
<button>
<SaveIcon /> {/* ← 何のボタンか伝わらない */}
</button>
問題② ラベルとアイコン名が両方読まれて冗長になる
パターン B は一見親切ですが、親要素に aria-label がある時点で、子の SVG に付けた aria-label は ARIA の仕様上、無視されます。
<button aria-label="保存する">
<SaveIcon aria-label="保存アイコン" /> {/* ← 「保存する ボタングループ」と読まれる */}
</button>
良かれと思って付けた aria-label は、機能していないどころか、コードを読んだ人を混乱させるノイズになっているだけです。
改善③ 単独のアイコンが完全に無視される
パターン C のような、それ単体で意味を持つアイコン(ステータス表示など)に何も指定しないと、スクリーンリーダーはこのアイコンの存在自体を伝えられません。
<CheckIcon /> {/* ← 「承認済み」という情報がユーザーに届かない */}
つまり、アイコンは「見た目」ではなく「そのアイコンが情報を持っているかどうか」で実装が変わる のです。
アイコンは 2 種類ある
書き換えに入る前に、アイコンの 2 つの分類を整理します。
| 種類 | 例 | スクリーンリーダーでの扱い |
|---|---|---|
| 装飾的アイコン | ボタンやリンクの中に添えるアイコン(保存ボタンのフロッピー、リンクの矢印など) | テキストラベルがあるので、アイコンは読まなくてよい |
| 情報を持つアイコン | アイコン単体で意味を伝えるもの(ステータス表示、凡例のグラフィックなど) | アイコンの意味を読み上げる必要がある |
ポイントは、「見た目」ではなく「そのアイコンが情報を持っているか」で実装が変わる ということです。
書き換え後
装飾的アイコン: 親に aria-label、アイコンは aria-hidden
ボタンやリンクの中にあるアイコンは、基本的に装飾扱いにします。
// アイコンのみのボタン
<button aria-label="保存する">
<SaveIcon aria-hidden="true" />
</button>
-
親要素(button)に
aria-labelを付けて、何のボタンかを伝える -
アイコンには
aria-hidden="true"を付けて、スクリーンリーダーから隠す
テキストとアイコンが両方ある場合も同じで、アイコンは隠します。
<button>
<SaveIcon aria-hidden="true" />
保存する
</button>
情報を持つアイコン: SVG 内に <title>
アイコン単体で意味を伝える「インフォグラフィック」的なものは、隠してはいけません。
SVG の中に <title> を持たせて、意味を読み上げられるようにします。
// ステータスを表す単独のアイコン
<svg role="img" aria-labelledby="status-title">
<title id="status-title">承認済み</title>
{/* パス省略 */}
</svg>
これで、スクリーンリーダーは「承認済み 画像」のように読み上げてくれます。
TypeScript の型で誤用を防ぐ
ここからが本題です。上のルールは理解しても、実装のたびに「これは装飾的だっけ?」と判断するのは事故のもとです。
そこで、判別可能なユニオン型(Discriminated Union) を使って、アイコンの用途を型レベルで強制します。
import { useId } from "react";
import type { SVGProps } from "react";
// 装飾的: ラベルは持てない(持たせる必要がない)
type DecorativeIconProps = {
variant: "decorative";
title?: never; // title を渡そうとすると型エラー
};
// 情報を持つ: title が必須
type InformativeIconProps = {
variant: "informative";
title: string; // SVG内の<title>要素として描画される(省略すると型エラー)
};
type IconProps = (DecorativeIconProps | InformativeIconProps) &
Omit<SVGProps<SVGSVGElement>, "title">;
export function Icon({ variant, title, ...rest }: IconProps) {
// フックは条件分岐の前に呼ぶ(Rules of Hooks)
const titleId = useId();
if (variant === "informative") {
return (
<svg role="img" aria-labelledby={titleId} {...rest}>
<title id={titleId}>{title}</title>
{/* パス */}
</svg>
);
}
// decorative
return <svg aria-hidden="true" focusable="false" {...rest} />;
}
useId() は React 18 以降で利用できます。古いバージョンを使っている場合は、uuid などの ID 生成ライブラリで代替してください。
この型のポイントは 2 つです。
-
informativeを選んだらtitleが必須 になる(付け忘れると型エラー) -
decorativeを選んだらtitleを渡せない(title?: neverにより、渡そうとすると型エラー)
使う側のコードはこうなります。
<Icon variant="informative" title="承認済み" /> // ✅ OK
<Icon variant="informative" /> // ❌ 型エラー: title が必要
<Icon variant="decorative" /> // ✅ OK
<Icon variant="decorative" title="保存" /> // ❌ 型エラー: title は渡せない
「装飾的なのに title を付けてしまう」「情報を持つのにラベルを忘れる」という 誤用を、コンパイル時に潰せる わけです。
まとめ
実装パターンごとの挙動
実装パターンごとに、Before / After の挙動を並べてみます。
| ケース | Before(規約だけで運用) | After(型で強制) |
|---|---|---|
| アイコンのみのボタン | ラベル付け忘れリスクあり |
informative 選択時に title 必須 |
| ラベル付きボタン内のアイコン | 二重ラベルになりがち |
decorative 選択時は title 渡せず防げる |
| ステータス単独アイコン |
aria-hidden 誤付与リスク |
informative 選択で確実にラベル表示 |
| レビュー時のチェック | 人の目で毎回確認 | 型エラーで自動検出 |
どのケースも、最終的な実装は同じです。
ただし、型で強制するだけで誤用が事前に防がれ、レビューのコストも下がります。
アクセシビリティは「気をつける」より「仕組みで守る」
アクセシビリティ対応そのものが難しいわけではない ということです。
難しいのは、チーム全員に毎回同じ品質で実装してもらうこと です。
「装飾的か / 情報を持つか」の判断を実装者の記憶や注意力に委ねていたことです。
ドキュメントに書いても、人が増えれば必ず抜けが出ますし、アクセシビリティの仕様が変わったり、適用レベルが上がったりすれば更新が必要です…。
判断が必要な実装ほど、判断そのものを型に委ねる。
これが、誰かではなく、チームでアクセシビリティを長期的に守るコツです。
仕組み化に取り組んでみてください!
本記事の要点は下記のとおりです。
- アイコンは「装飾的」か「情報を持つ」かで実装が変わる
- 装飾的(ボタン・リンク内)→ 親に
aria-label、アイコンにaria-hidden="true" - 情報を持つ(単独)→ SVG 内に
<title>/aria-label - この区別を 判別可能なユニオン型 で表現すれば、誤用をコンパイル時に防げる
- 個人の意識ではなく 仕組みで守る ことで、チーム全員が同じ品質で実装できる
- ドキュメントを読まなくても正しく書ける状態を作るのが、チームでアクセシビリティを根付かせるコツ
AI に任せることが多くなった昨今ですが、アクセシビリティのような「気をつければできる」領域こそ、仕組みで守る設計を自分で考えられるようにしておきたいですね!
チームの体制や運用面について知りたい方は下記の記事や関連記事を参考にしてみてください。