実家に帰ると、親のスマホの文字が、驚くほど大きく設定されている。
ホーム画面のアイコンの名前は2文字ずつで改行され、LINEのメッセージは一画面に3行しか表示されない。思わず笑ってしまいそうになりますが、本人は「これでやっと読める」と真剣です。
そういう方がWebサイトを開いたとき、何が起きているのか。考えたことはあるでしょうか。
アメリカの非営利団体WebAIMは、毎年、アクセス数の多い上位100万サイトのトップページを自動で検査し、アクセシビリティの問題を集計しています。2026年2月の調査では、83.9%のトップページで、背景との色の差(コントラスト)が足りない文字が見つかりました。前年の79.1%から、さらに増えています(WebAIM Million 2026)。
つまり、Webサイトの8割以上に、誰かにとっては「薄くて読めない」文字が含まれている、ということです。
この記事では、WebAIMの調査で毎年上位に並ぶ6つのエラーを、Pythonの標準ライブラリだけで点検するスクリプトを作ります。コントラスト比の計算式を一行ずつ読み解き、「基準を満たす一番近い色」を探し、HTMLから代替テキストやラベルの抜けを見つけます。
この記事で分かること
- 「ウェブアクセシビリティは義務化された」という話の、正確なところ
- WebAIM Million で、7年間変わらず上位に並ぶ6つのエラー
- WCAGのコントラスト比の計算式と、その実装
- 色相を保ったまま、基準を満たす一番近い色を探す方法
- 代替テキスト、フォームのラベル、空のリンク・ボタン、ページの言語を、HTMLから点検する方法
- 自動チェックでは分からないこと
コードは Python 3.12 の標準ライブラリだけで動作を確認しています。
序章:「義務化された」は、正確ではない
本題に入る前に、一つ、よく見かける誤解を整理しておきます。
「2024年4月から、ウェブアクセシビリティが義務化された」という話を、聞いたことがある方も多いと思います。正確には、こうです。
2024年4月1日に施行された改正障害者差別解消法で、民間の事業者にも、障害のある人への**「合理的配慮の提供」が義務になりました。一方、ウェブアクセシビリティへの取り組みは、合理的配慮を提供しやすくするための「環境の整備」として位置づけられていて、こちらは努力義務**です(政府広報オンライン)。
だから、「義務化されたから、対応しなければ罰せられる」と言うのは、正確ではありません。
ただ、日本の規格の動きもあります。ウェブアクセシビリティの日本産業規格である JIS X 8341-3 は、2016年版から約10年ぶりの改正に向けた作業が進んでいて、国際規格(ISO/IEC 40500:2025)にあわせて、WCAG 2.2 の内容を取り込む方向で検討されています(ウェブアクセシビリティ基盤委員会)。
でも、私がこの記事で伝えたいのは、法律や規格の話ではありません。読めない人がいる、という話です。
第1章:7年間、同じ6つのエラー
WebAIM Million 2026 で、もっとも多く見つかったエラーは、次の6つでした。
| 順位 | エラー | 見つかったトップページの割合 |
|---|---|---|
| 1 | 文字と背景のコントラストが低い | 83.9% |
| 2 | 画像に代替テキストがない | 53.1% |
| 3 | フォームの入力欄にラベルがない | 51.0% |
| 4 | 中身が空のリンク | 46.3% |
| 5 | 中身が空のボタン | 30.6% |
| 6 | ページの言語が指定されていない | 13.5% |
レポートによると、検出されたエラー全体の**96%**が、この6つに集中しています。そして、この上位6つは、ここ7年間ずっと同じなのだそうです。
裏を返せば、これは希望のある話でもあります。この6つを直すだけで、自動で見つかる問題のほとんどが片づくのです。どれも、難しい技術が必要なものではありません。知っていれば防げる、知らなければ毎年同じように繰り返してしまう。そういう種類の問題です。
では、一つずつ、Pythonで点検していきましょう。
第2章:コントラスト比を、計算する
計算式
1位のコントラスト比から始めます。WCAG(Web Content Accessibility Guidelines)では、2つの色の「コントラスト比」を、次のように定義しています。
$$
\text{コントラスト比} = \frac{L_1 + 0.05}{L_2 + 0.05}
$$
$L_1$ は明るいほうの色の相対輝度、$L_2$ は暗いほうの色の相対輝度です。真っ黒と真っ白の組み合わせで最大の21:1、同じ色どうしで最小の1:1になります。
相対輝度は、RGBの各色をいったん「人の目に見える明るさ」に近い値に変換してから、重みをつけて足し合わせたものです。
def _channel(c8: int) -> float:
c = c8 / 255
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
def luminance(hex_color: str) -> float:
h = hex_color.lstrip("#")
r, g, b = (int(h[i:i + 2], 16) for i in (0, 2, 4))
return 0.2126 * _channel(r) + 0.7152 * _channel(g) + 0.0722 * _channel(b)
def contrast(fg: str, bg: str) -> float:
l1, l2 = sorted((luminance(fg), luminance(bg)), reverse=True)
return (l1 + 0.05) / (l2 + 0.05)
重みを見ると、緑が71.5%、赤が21.3%、青は7.2%しかありません。人の目は、緑の明るさの変化にいちばん敏感で、青の変化には鈍いのです。真っ青な背景に黒い文字が意外と読みにくいのは、このためです。
0.03928 と 0.04045
この式を、ほかの記事やライブラリと見比べた方は、0.04045 の部分が 0.03928 になっているものを見かけたことがあるかもしれません。
WCAG 2.2 の仕様書には、ちゃんと注記があります。2021年5月より前は、ここが 0.03928 だった。古い版の sRGB の仕様から取られた値で、その後 0.04045 に更新された。そして、このガイドラインの計算においては、実質的な影響はない、と。
本当に影響がないのか、気になったので確かめてみました。8ビットの色(0〜255)のすべての値について、2つのしきい値で判定が変わるものがあるかを調べます。
>>> [c for c in range(256) if (c/255 <= 0.03928) != (c/255 <= 0.04045)]
[]
空っぽでした。0.03928 と 0.04045 のあいだに入る8ビットの値は、一つもありません(10/255 ≒ 0.0392 は両方より下、11/255 ≒ 0.0431 は両方より上)。ふだんWebで使うカラーコードの範囲では、どちらの式を使っても、結果はまったく同じになるわけです。仕様書の「実質的な影響はない」は、文字どおりの意味でした。
検算
自分で書いた式は、検算しておきます。
>>> round(contrast("#000000", "#ffffff"), 2)
21.0
>>> round(contrast("#767676", "#ffffff"), 3)
4.542
>>> round(contrast("#777777", "#ffffff"), 3)
4.478
黒と白で21:1。そして、#767676 が白い背景で4.54:1、#777777 だと4.48:1です。白い背景で、通常の文字の基準(4.5:1)を満たすいちばん薄いグレーは #767676 で、そこから1段階でも薄くすると基準を下回る。これは、アクセシビリティに関わる人のあいだでよく知られた「境界線」なので、式が正しく実装できていることが確かめられます。
基準の値
WCAG 2.2 の達成基準 1.4.3(コントラスト・最低限、レベルAA)では、次のように決められています。
| 対象 | 必要なコントラスト比 |
|---|---|
| 通常の文字 | 4.5:1 以上 |
| 大きな文字(18ポイント以上、または14ポイント以上の太字) | 3:1 以上 |
より厳しいレベルAAAでは、通常の文字で7:1、大きな文字で4.5:1が求められます。
ちなみに、WCAGの「大きな文字」の定義には、中国語・日本語・韓国語(CJK)のフォントについて、これと同等の大きさになるサイズ、という但し書きがついています。漢字やかなは、同じポイント数でも、アルファベットより細部が多くて読みにくいことがあるからです。日本語のサイトでは、「18ポイントあれば3:1でいい」と機械的に考えず、少し余裕を持たせるのが安心です。
第3章:基準を満たす、一番近い色を探す
「この色じゃダメです」だけでは、デザイナーは困る
コントラスト比が足りないことが分かっても、「NG」と言うだけでは、デザインを直す人は困ってしまいます。どの色なら大丈夫なのか、が知りたいはずです。
そこで、色相と彩度はそのままに、明るさだけを少しずつ動かして、基準を満たす一番近い色を探す関数を書きました。ブランドの色合いを、できるだけ保ったまま直すためです。
def nearest_passing(color: str, other: str, target: float = 4.5) -> str | None:
"""color の色相と彩度はそのままに、明るさだけを動かして、other との組み合わせで基準を満たす一番近い色を探す"""
h = color.lstrip("#")
r, g, b = (int(h[i:i + 2], 16) / 255 for i in (0, 2, 4))
hue, light, sat = colorsys.rgb_to_hls(r, g, b)
darker = luminance(color) <= luminance(other) # 相手より暗い色なら、さらに暗くする
for step in range(1, 1001):
l = light - step / 1000 if darker else light + step / 1000
if not 0 <= l <= 1:
break
cand = "#" + "".join(f"{round(v * 255):02x}" for v in colorsys.hls_to_rgb(hue, l, sat))
if contrast(cand, other) >= target:
return cand
return None
標準ライブラリの colorsys で、RGBを HLS(色相・明度・彩度) に変換し、明度だけを0.001刻みで動かしています。
最初は0.01刻みで書いていたのですが、#999999 を直した結果が #757575 になってしまいました。基準は満たしていますが、さっきの検算で見たとおり、本当は #767676 で足りるはずです。刻みが粗すぎて、一番近い色を飛び越えていたのです。0.001刻みにして、ようやく #767676 が返ってくるようになりました。
第4章:HTMLの、残り5つを点検する
パーサー
2位から6位の5つは、HTMLを読めば判定できます。標準ライブラリの html.parser を使います。
点検の全体のコードは、こうなりました。
"""WebAIM Million で毎年上位に並ぶ「6つのエラー」を、標準ライブラリだけで点検する。
1. コントラスト比(色の組み合わせを渡して判定)
2. 画像の代替テキスト / 3. フォームのラベル / 4. 空のリンク / 5. 空のボタン / 6. ページの言語
(2〜6 は HTML を読んで判定)
"""
import colorsys
import sys
from html.parser import HTMLParser
# ---------- 1. コントラスト比(WCAG 2.x の定義) ----------
def _channel(c8: int) -> float:
c = c8 / 255
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
def luminance(hex_color: str) -> float:
h = hex_color.lstrip("#")
r, g, b = (int(h[i:i + 2], 16) for i in (0, 2, 4))
return 0.2126 * _channel(r) + 0.7152 * _channel(g) + 0.0722 * _channel(b)
def contrast(fg: str, bg: str) -> float:
l1, l2 = sorted((luminance(fg), luminance(bg)), reverse=True)
return (l1 + 0.05) / (l2 + 0.05)
def nearest_passing(color: str, other: str, target: float = 4.5) -> str | None:
"""color の色相と彩度はそのままに、明るさだけを動かして、other との組み合わせで基準を満たす一番近い色を探す"""
h = color.lstrip("#")
r, g, b = (int(h[i:i + 2], 16) / 255 for i in (0, 2, 4))
hue, light, sat = colorsys.rgb_to_hls(r, g, b)
darker = luminance(color) <= luminance(other) # 相手より暗い色なら、さらに暗くする
for step in range(1, 1001):
l = light - step / 1000 if darker else light + step / 1000
if not 0 <= l <= 1:
break
cand = "#" + "".join(f"{round(v * 255):02x}" for v in colorsys.hls_to_rgb(hue, l, sat))
if contrast(cand, other) >= target:
return cand
return None
# ---------- 2〜6. HTML の点検 ----------
class A11yParser(HTMLParser):
def __init__(self):
super().__init__()
self.problems: list[str] = []
self.has_lang = False
self.label_for: set[str] = set()
self.inputs: list[tuple[int, dict]] = []
self.label_depth = 0
self.stack: list[dict] = [] # 開いている a / button ごとに「中身があるか」を記録
def handle_starttag(self, tag, attrs):
a = dict(attrs)
line = self.getpos()[0]
if tag == "html" and a.get("lang", "").strip():
self.has_lang = True
if tag == "img":
if "alt" not in a: # alt="" は「飾りの画像」の正しい書き方なので、問題にしない
self.problems.append(f"{line}行目: img に alt 属性がない({a.get('src', '')})")
for item in self.stack:
item["content"] = item["content"] or bool(a.get("alt", "").strip())
if tag == "label":
self.label_depth += 1
if a.get("for"):
self.label_for.add(a["for"])
if tag in ("input", "select", "textarea") and a.get("type") not in ("hidden", "submit", "button", "image"):
a["_in_label"] = self.label_depth > 0
self.inputs.append((line, a))
if tag in ("a", "button"):
named = bool(a.get("aria-label", "").strip() or a.get("aria-labelledby"))
self.stack.append({"tag": tag, "line": line, "content": named})
def handle_endtag(self, tag):
if tag == "label":
self.label_depth = max(0, self.label_depth - 1)
if tag in ("a", "button") and self.stack and self.stack[-1]["tag"] == tag:
item = self.stack.pop()
if not item["content"]:
kind = "リンク" if tag == "a" else "ボタン"
self.problems.append(f"{item['line']}行目: 中身が空の{kind}(読み上げで何のことか分からない)")
def handle_data(self, data):
if data.strip():
for item in self.stack:
item["content"] = True
def finish(self) -> list[str]:
for line, a in self.inputs:
labelled = (a["_in_label"] or a.get("id") in self.label_for
or a.get("aria-label", "").strip() or a.get("aria-labelledby"))
if not labelled:
self.problems.append(f"{line}行目: {a.get('name', '入力欄')} にラベルがない")
self.problems.sort(key=lambda s: int(s.split("行目")[0]))
if not self.has_lang:
self.problems.insert(0, "html 要素に lang 属性がない(読み上げの言語が決まらない)")
return self.problems
def check_html(text: str) -> list[str]:
p = A11yParser()
p.feed(text)
p.close()
return p.finish()
if __name__ == "__main__":
print("■ 色の組み合わせ(基準: 通常の文字 4.5:1 以上)")
pairs = [("#999999", "#ffffff", "本文の薄いグレー", "文字色"),
("#767676", "#ffffff", "ぎりぎり合格のグレー", "文字色"),
("#ffffff", "#ff8c00", "オレンジのボタンに白文字", "背景色"),
("#333333", "#ffffff", "濃いグレーの本文", "文字色")]
for fg, bg, name, adjust in pairs:
r = contrast(fg, bg)
if r >= 4.5:
verdict = "OK"
else:
fixed = nearest_passing(fg, bg) if adjust == "文字色" else nearest_passing(bg, fg)
verdict = f"NG → {adjust}を {fixed} にすると基準を満たす"
print(f" {r:5.2f}:1 {name}({fg} / {bg}) {verdict}")
if len(sys.argv) > 1:
print(f"■ HTML({sys.argv[1]})")
for problem in check_html(open(sys.argv[1], encoding="utf-8").read()) or ["問題は見つかりませんでした"]:
print(" - " + problem)
実行してみる
説明のために、架空の整骨院のサイトのHTMLを用意しました。どこにでもありそうな、小さなサイトです。
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>さくら整骨院</title></head>
<body>
<header>
<a href="/"><img src="/logo.png"></a>
<a href="/menu"><span class="icon-menu"></span></a>
</header>
<main>
<img src="/hero.jpg" alt="明るい待合室">
<img src="/line.svg" alt="">
<form>
<input type="text" name="name" placeholder="お名前">
<label for="tel">電話番号</label>
<input type="tel" id="tel" name="tel">
<label>メール <input type="email" name="email"></label>
<button type="submit"><svg aria-hidden="true"></svg></button>
<button type="button" aria-label="閉じる">×</button>
</form>
</main>
</body>
</html>
$ python a11y_check.py sample.html
■ 色の組み合わせ(基準: 通常の文字 4.5:1 以上)
2.85:1 本文の薄いグレー(#999999 / #ffffff) NG → 文字色を #767676 にすると基準を満たす
4.54:1 ぎりぎり合格のグレー(#767676 / #ffffff) OK
2.33:1 オレンジのボタンに白文字(#ffffff / #ff8c00) NG → 背景色を #b36200 にすると基準を満たす
12.63:1 濃いグレーの本文(#333333 / #ffffff) OK
■ HTML(sample.html)
- html 要素に lang 属性がない(読み上げの言語が決まらない)
- 6行目: img に alt 属性がない(/logo.png)
- 6行目: 中身が空のリンク(読み上げで何のことか分からない)
- 7行目: 中身が空のリンク(読み上げで何のことか分からない)
- 13行目: name にラベルがない
- 17行目: 中身が空のボタン(読み上げで何のことか分からない)
一つずつ、読み解く
コントラスト:本文によく使われがちな薄いグレー #999999 は2.85:1で、基準に届きません。#767676 にすれば合格です。見た目の印象はほとんど変わらないのに、読みやすさは大きく変わります。
オレンジのボタンに白い文字(#ff8c00 に #ffffff)は、わずか2.33:1。このスクリプトは、背景のオレンジを #b36200 まで暗くすれば基準を満たす、と教えてくれます。ただし、このときのコントラスト比はちょうど4.50:1です。基準ぎりぎりなので、実際のデザインでは、もう一段階暗くして余裕を持たせるのがおすすめです。画面の明るさや、見る環境によって、色の見え方は変わるからです。
ページの言語:<html> に lang 属性がありません。スクリーンリーダー(画面の読み上げソフト)は、この属性を見て、どの言語の発音で読み上げるかを決めます。日本語のページなら <html lang="ja">。たった9文字で直せるエラーが、上位100万サイトの13.5%に残っているのです。
代替テキスト:ロゴの画像に alt 属性がありません。読み上げソフトは、代わりにファイル名を読み上げたり、何も読み上げなかったりします。
ここで大事なのは、alt=""(空の代替テキスト)は、エラーではないということです。区切り線のような飾りの画像には、alt="" と書くのが正しい書き方で、「この画像は読み上げなくていい」という意味になります。スクリプトでも、alt 属性そのものがないときだけを問題にしています。「alt がない」と「alt が空」は、まったく別のものです。
空のリンク:6行目のロゴのリンクと、7行目のメニューのアイコンのリンクは、どちらも、読み上げソフトにとっては中身が空です。画像の alt がなかったり、アイコンを span のCSSで描いていたりするからです。読み上げると、「リンク」とだけ聞こえて、どこに行くリンクなのか分かりません。
空のボタン:送信ボタンの中身が、aria-hidden="true" のSVGアイコンだけです。見た目には紙飛行機のアイコンがあっても、読み上げでは「ボタン」としか聞こえません。一方、「閉じる」ボタンは、中身が「×」の記号でも、aria-label="閉じる" があるので問題ありません。
フォームのラベル:「お名前」の入力欄は、placeholder="お名前" があるだけで、ラベルがありません。placeholder はラベルの代わりにはなりません。入力を始めると消えてしまうし、読み上げソフトによっては読まれないこともあります。電話番号は <label for="tel"> と id="tel" で、メールは <label> で囲む形で、それぞれ正しく関連づけられています。
第5章:自動チェックで、分からないこと
ここまで来ると、「このスクリプトで問題がゼロになれば、アクセシビリティは完璧」と思いたくなります。でも、それは違います。
WebAIM Million 自体も、自動で検出できるエラーだけを集計した調査です。自動チェックには、はっきりした限界があります。
-
代替テキストの「質」は分からない:ロゴの
altに「画像」と書いてあっても、このスクリプトは合格にします。本当に必要なのは、「さくら整骨院 トップページへ」のような、その画像の役割が伝わる説明です - キーボードだけで操作できるかは分からない:マウスを使わず、Tabキーだけでメニューを開けるか、フォームを送信できるか
- 文字を拡大したときに崩れないかは分からない:冒頭の、文字を大きく設定している人の話です。WCAG 2.2 では、文字を200%まで拡大しても、内容や機能が失われないことが求められています(達成基準 1.4.4)
- 画像の中の文字や、CSSで重ねた色は分からない:このスクリプトは、指定した色の組み合わせしか判定しません。実際のページでは、背景画像の上に文字が乗っていることもあります
自動チェックは、健康診断の血液検査のようなものだと思っています。数値に異常があれば、確実に何かが起きている。でも、数値が正常だからといって、どこも悪くないとは限らない。最後は、実際に使ってみることです。スマホの読み上げ機能(iPhoneのVoiceOver、AndroidのTalkBack)をオンにして、自分のサイトを一度操作してみてください。目をつぶって、5分だけ。それだけで、このスクリプトでは見つからない問題に、たくさん気づけるはずです。
WCAG 3 は、まだ先の話
ちなみに、次の世代の WCAG 3 の策定も進んでいますが、まだ草案(Working Draft)の段階です。WCAG 3 では、コントラストの新しい計算方法として APCA という方式が話題になっていましたが、現在の草案には含まれていません。「WCAG 3 ではAPCAになるから」と、今のWCAG 2のコントラスト比の基準を先回りして捨ててしまうのは、まだ早いと思います。
第6章:明日は我が身
最後に、アクセシビリティは「誰のため」のものなのか、という話をさせてください。
Microsoftが公開しているインクルーシブデザインの考え方に、障害を**「永続的」「一時的」「状況的」**の3つに分けて考える、というものがあります。
- 永続的:片腕がない、目が見えない
- 一時的:腕を骨折している、白内障の手術のあと
- 状況的:赤ちゃんを抱っこしていて片手しか使えない、強い日差しの下でスマホの画面が見えにくい
コントラストの低い文字は、目の見えにくい方にとって読みにくいだけではありません。夏の屋外で、スマホを見ている人にとっても読みにくいのです。フォームのラベルがないと、読み上げソフトを使う人だけでなく、入力の途中で「ここ、何を書く欄だっけ」と迷った人も困ります。
そして、冒頭の、スマホの文字を大きくしている親の姿。あれは、いずれ私たちの姿でもあります。
「明日は我が身」という言葉があります。アクセシビリティは、誰か特別な人のための配慮ではなく、将来の自分を含めた、すべての人のための品質なのだと思います。
まとめ:6つのエラーの点検表
| エラー | 点検のポイント | 直し方 |
|---|---|---|
| コントラストが低い | 通常の文字は4.5:1、大きな文字は3:1以上 | 明るさだけを動かして、基準を満たす色を探す。ぎりぎりより、少し余裕を |
| 代替テキストがない |
alt 属性そのものがないか |
意味のある画像には役割を書く。飾りの画像は alt=""
|
| ラベルがない |
label や aria-label で関連づいているか |
placeholder はラベルの代わりにならない |
| 空のリンク | 読み上げたときに、行き先が分かるか | 画像の alt か、aria-label で名前をつける |
| 空のボタン | 読み上げたときに、何のボタンか分かるか | アイコンだけのボタンには aria-label
|
| 言語の指定がない |
<html> に lang があるか |
<html lang="ja"> |
おわりに:大きな文字でも、崩れないように
冒頭の、文字を大きく設定したスマホの話に戻ります。
あの設定で、自分が作ったサイトを開いてみたことはありますか。見出しがはみ出していないか。ボタンの文字が切れていないか。メニューが重なって押せなくなっていないか。
私は、サイトを納品する前に、スマホの文字サイズを最大にして、一通り操作してみることにしています。たいてい、どこか一か所は崩れています。そして、それを直すたびに、「あ、これは親が見たら困るところだった」と思うのです。
次に実家に帰ったときは、親のスマホで、自分の作ったサイトを開いてみてください。そこで見つかる問題は、どんな自動チェックよりも正確な、本物の利用者からのバグ報告です。
最後まで読んでいただき、ありがとうございました。「読み上げ機能で自分のサイトを操作してみたら、こんなことに気づいた」という体験があれば、ぜひコメントで教えてください。いいね・ストックをしていただけると、とても励みになります。
