4
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

「文字が薄くて読めない」は、上位100万サイトの8割で起きている ― WebAIM Millionの6大エラーを、Pythonの標準ライブラリだけで点検する

4
Last updated at Posted at 2026-08-14

Watercolor Sunset Accessibility in a Japanese Room.png

実家に帰ると、親のスマホの文字が、驚くほど大きく設定されている。

ホーム画面のアイコンの名前は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 を使います。

点検の全体のコードは、こうなりました。

a11y_check.py
"""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を用意しました。どこにでもありそうな、小さなサイトです。

sample.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">

おわりに:大きな文字でも、崩れないように

冒頭の、文字を大きく設定したスマホの話に戻ります。

あの設定で、自分が作ったサイトを開いてみたことはありますか。見出しがはみ出していないか。ボタンの文字が切れていないか。メニューが重なって押せなくなっていないか。

私は、サイトを納品する前に、スマホの文字サイズを最大にして、一通り操作してみることにしています。たいてい、どこか一か所は崩れています。そして、それを直すたびに、「あ、これは親が見たら困るところだった」と思うのです。

次に実家に帰ったときは、親のスマホで、自分の作ったサイトを開いてみてください。そこで見つかる問題は、どんな自動チェックよりも正確な、本物の利用者からのバグ報告です。


最後まで読んでいただき、ありがとうございました。「読み上げ機能で自分のサイトを操作してみたら、こんなことに気づいた」という体験があれば、ぜひコメントで教えてください。いいね・ストックをしていただけると、とても励みになります。

4
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?