はじめに
AI エージェントにアクセシビリティチェックをやらせる検証をしていたとき、ツールによって「h1 のアクセシブルネームがある/ない」の判定が食い違う現象に遭遇しました。
DOM を見ても答えは出ず、決着をつけたのは Chrome が内部に持つ AX ツリー(Accessibility Tree) でした。
この記事では、その経験をきっかけに調べた AX ツリーについてまとめます。
- スクリーンリーダーが実際に「読んでいるもの」の正体
- アクセシブルネームがどう計算されるか(仕様の該当箇所つき)
- AX ツリーを手元で覗く4つの方法
- DOM だけ見ていると気づけない落とし穴
検証に使った環境は macOS / Chrome(Chromium 系)です。
1. スクリーンリーダーが読んでいるのは DOM ではない
Chrome はページをレンダリングするとき、DOM ツリーとは別に、支援技術(スクリーンリーダー等)専用のもう一つのツリーを内部で構築しています。
これが AX ツリーです。
VoiceOver が「見出しレベル1、◯◯」と読み上げるとき、参照しているのは DOM ではなく、AX ツリーをプラットフォームのアクセシビリティ API(macOS なら NSAccessibility、Windows なら UIA/IA2)に変換したものです。
厳密には、支援技術側がこの API 表現からさらに独自の内部表現を再構築することもあるため、「AX ツリー」と一口に言っても Blink 内部のツリー・プラットフォーム API 上の表現・各ツールが出力する表現、という複数の層があります。
この記事で「Chrome の AX ツリー」と呼ぶのは Blink 内部のツリー(DevTools や CDP が参照するもの)です。
つまり、アクセシビリティの検証で「実ユーザーに届く最終形」に最も近いデータは、DOM ではなく AX ツリーです。
DOM のマークアップが意図どおりでも、AX ツリー上でどう解釈されたかは別問題として確認する必要があります。
2. AX ツリーには何が入っているか
AX ツリーの各ノードは、DOM 要素から計算された結果を持ちます。
| 項目 | 例 | 何から決まるか |
|---|---|---|
| role |
heading、link、button
|
HTML 要素の暗黙のロール、または ARIA の明示的なロール |
| name | 「お問い合わせ」 | AccName 仕様のアルゴリズムによる計算結果 |
| states / properties |
level=1、expanded、disabled
|
属性・ARIA・要素の状態 |
| リレーション | ラベル関係、セル↔ヘッダ関係 |
aria-labelledby、テーブル構造など |
重要なのは、AX ツリーは DOM と 1:1 に対応しないことです。
-
display: noneやaria-hidden="true"の要素はツリーから省かれる - 意味を持たない
<div>/<span>はgenericロールになるか、"ignored" ノードとして扱われる - CSS の
contentで追加された擬似要素のテキストが名前計算に含まれることがある
「DOM を見て正しくても、AX ツリーでは違う」が普通に起きるため、a11y の検証は AX ツリーを見るのが筋です。
3. アクセシブルネームはどう計算されるか
AX ツリーの name は、Accessible Name and Description Computation 1.2(AccName)で定義されたアルゴリズムで計算されます。
ざっくりした優先順位はこうです。
-
aria-labelledby(参照先のテキスト) aria-label- ホスト言語のラベル機構(
<label>、alt属性など) - name from content(そのロールが対応していれば、中身のテキストから再帰的に計算)
-
title属性などのフォールバック
DOM だけ見ると混乱するケース
自分が遭遇したのはこういう構造の h1 でした。
<h1>
<span aria-label="Gaji-Labo(ガジラボ)">
<svg>...</svg> <!-- ロゴ。テキストなし -->
</span>
</h1>
h1 自身にはラベル系属性がなく、中身は「テキストを持たない SVG を包んだ span」だけです。
この h1 の名前はどうなるでしょうか。
答えは「Gaji-Labo(ガジラボ)」です。
h1 は name from content で名前を計算し、その再帰の途中で子孫 span の aria-label を採用します。
実際に Chrome の AX ツリーを CDP 経由(puppeteer の page.accessibility.snapshot())で取得して確認した結果がこちらです。
{
"role": "heading",
"name": "Gaji-Labo(ガジラボ)",
"level": 1
}
検証環境: Chrome/146.0.7680.153(puppeteer 経由)。
別日に2回実行して同じ結果になることを確認しています。
取得に使ったスクリプトは後述の「4-3. CDP / puppeteer」のものと同じ要領です。
ここで面白いのが仕様の細部です。
素の span(role=generic)を aria-label で直接命名する設計は、ARIA 1.2 では naming prohibited とされています。
prohibited: the element does not support name from author. Authors MUST NOT use the
aria-labeloraria-labelledbyattributes to name the element.— WAI-ARIA 1.2 - 5.2.8 Accessible Name Calculation(prohibited なロールの一覧は 5.2.8.6 Roles which cannot be named (Name prohibited)、
genericもその一つです)
それでも h1 の名前になるのは、AccName の計算手順で prohibited チェックが行われるのが root node(名前計算の起点)に対してだけだからです。
Initialization: Set the
root nodeto the given element, (中略). If theroot node's role prohibits naming, return the empty string ("").— Accessible Name and Description Computation 1.2 - 4.3.2 Computation steps
この設計意図は AccName 1.2 の Change Log(2019-06-27)にも明記されています。
even if this prohibition is made within a specification, that prohibition will not have any impact on calculating name from contents.
— Accessible Name and Description Computation 1.2 - Change Log
つまり「span 自身は命名できないが、親の名前計算には寄与する」という挙動は Chrome の独自救済ではなく、仕様どおりです。
DOM のマークアップと ARIA の禁止事項だけを見ていると「この aria-label は無効なはず」と誤読しかねないケースで、AX ツリーを見れば一発で答えが分かります。
とはいえ、この書き方は堅牢とは言えません。
naming prohibited なロールへの aria-label はブラウザ間で解釈差が出うるため、svg に role="img" + aria-label を付けるか、visually hidden なテキストを置くほうが安全です。
4. AX ツリーを覗く4つの方法
4-1. DevTools の Accessibility ペイン(個別調査向け)
Elements パネルで要素を選択して「Accessibility」タブを開くと、その要素の計算済み name / role と、名前計算にどのソースが採用されたか(どの aria-label・テキストが勝ったか)まで表示されます。
先ほどの h1 のようなケースの調査はこれが一番手軽です。
4-2. DevTools のフル AX ツリー表示(構造の俯瞰向け)
Accessibility タブの「Show accessibility tree(ユーザー補助ツリーを表示する)」をオンにすると、Elements パネルの DOM ツリー表示がページ全体の AX ツリー表示に置き換わります。
ノードを選択したまま DOM ツリー表示に戻れるので、DOM ノードと AX ノードの対応関係を確認するのにも便利です。
見出し構造やランドマークの全体像を確認するのに向いています。
公式ドキュメント: Accessibility features reference - Chrome for Developers
4-3. CDP / puppeteer(プログラムから取る)
Chrome DevTools Protocol の Accessibility ドメイン経由で、プログラムから AX ツリーを取得できます。
puppeteer なら page.accessibility.snapshot() が手軽です。
const puppeteer = require('puppeteer');
(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('https://example.com/', { waitUntil: 'networkidle2' });
const snap = await page.accessibility.snapshot();
console.log(JSON.stringify(snap, null, 2));
await browser.close();
})();
snapshot() は既定で interesting なノードだけに剪定されます。
剪定なしのツリーが必要な場合は snapshot({ interestingOnly: false }) を渡すか、CDP の Accessibility.getFullAXTree を直接使う方法もあります。
CI に組み込んで「重要な要素の name が空になっていないか」を検証する、といった使い方ができます。
4-4. chrome://accessibility(内部デバッグ向け)
Chrome のアドレスバーに chrome://accessibility と入力すると、タブごとの AX ツリーの生ダンプが見られます。
ここで見えるのはあくまでブラウザ内部の AX ツリーであり、VoiceOver 等が実際に受け取るプラットフォーム API 表現(NSAccessibility など)そのものではない点には注意してください。
普段使いには過剰ですが、ツール類を介さず内部ツリーを直接見たいときの最終手段です。
5. 落とし穴
AX ツリーはブラウザごとに別実装
名前計算(AccName)やロールのマッピング(HTML-AAM / Core-AAM)には W3C の仕様がありますが、内部ツリーをどういう形で構築するかは実装依存で、Chrome(Blink)、Firefox(Gecko)、Safari(WebKit)がそれぞれ構築します。
仕様のグレーゾーン(先ほどの naming prohibited の子孫のようなケース)では、ブラウザ間で結果が異なる可能性もあります。
ツールが見せる「AX ツリーの写し」は加工されている
テスト自動化ツールや AI エージェント向けツールが出力するアクセシビリティツリーは、Chrome の AX ツリーそのものではなく、各ツールが独自に加工・再レンダリングした表現です。
自分が遭遇した「ツールによって h1 の name の有無が食い違う」現象も、調査した範囲ではこの加工の差によるものと考えられました。
ツールの出力を疑うときは、CDP や DevTools で Chrome 本体の AX ツリーに立ち返るのが確実です。
AX ツリー=読み上げ結果、でもない
AX ツリーはプラットフォーム API への変換層を経て支援技術に渡り、支援技術側にも独自の解釈があります。
また、スクリーンリーダーが発話するのは accessible name 単体ではなく、role・state・value なども組み合わせた文です(「お問い合わせ、リンク」のように)。
AX ツリーで正しくても、最終確認は実機のスクリーンリーダー(VoiceOver / NVDA など)で行う必要があります。
まとめ
- スクリーンリーダーが読んでいるのは DOM ではなく AX ツリー(の変換結果)
- AX ツリーのノードは role / name / states の「計算済みの結果」を持ち、DOM と 1:1 ではない
- アクセシブルネームは AccName 仕様のアルゴリズムで決まり、DOM の見た目からは直感に反する結果になることがある
- 覗く方法は DevTools(個別 / 全体)、CDP / puppeteer、chrome://accessibility の4つ
- ツールの出力は「AX ツリーの写し」なので、食い違ったら Chrome 本体の AX ツリーに立ち返る
AI エージェントや自動化ツールにアクセシビリティチェックを任せる場面が増えるほど、「そのツールは何を見て判定しているのか」を知っておく価値は上がると感じています。
AX ツリーはその一次ソースです。
認識が間違っている箇所があれば、コメントで教えていただけると嬉しいです。

