アイコンだけのボタンを作ったとき、スクリーンリーダーの利用者にはそれが何のボタンなのか伝わりません。装飾のために置いた記号が、そのまま「バツ」「三角」と読み上げられてしまうこともあります。こうした「見た目では分かるのに、機械には伝わっていない情報」を補うための仕組みが WAI-ARIA です。この記事では、WAI-ARIA が何をするものなのかというところから始めて、role 属性の役割、最初に覚えるべき「ネイティブの HTML 要素を優先する」という原則、そして aria-label / aria-hidden をはじめとする代表的な属性の使い方を、実際のコードとあわせて解説します。
目次
WAI-ARIA は見た目を変えずに意味を伝える仕組み
WAI-ARIA(Web Accessibility Initiative – Accessible Rich Internet Applications)は、W3C が定めている仕様で、一般には ARIA と略して呼ばれます。HTML の要素に対して「この要素は何なのか(ロール)」「いまどんな状態なのか(ステート)」「どんな性質を持つのか(プロパティ)」を付け足すための属性群だと考えてください。
ここでいちばん大事な性質は、ARIA を書いてもページの見た目も動作も一切変わらないということです。role="button" と書いてもボタンらしい見た目になりませんし、クリックできるようにもなりません。aria-expanded="true" と書いてもメニューが開くわけではありません。ARIA が変えるのは、ブラウザーが OS のアクセシビリティ API に渡す情報だけです。
ブラウザーは DOM とは別に、支援技術(スクリーンリーダー、音声入力ソフト、拡大鏡など)向けのアクセシビリティツリーという構造を組み立てています。ここには「役割は button」「名前は 検索」「押されている状態」といった情報が入っており、スクリーンリーダーはこの木をたどって「検索、ボタン」のように読み上げます。ARIA 属性は、このアクセシビリティツリーの中身を書き換えるための手段です。
つまり ARIA は、見た目の実装(CSS)と実際の意味(機械が読む情報)がずれてしまったときに、そのずれを埋めるための道具です。逆に言えば、ずれていないなら書く必要はありません。この「必要なときだけ書く」という感覚が、次に説明する原則につながります。
No ARIA is better than Bad ARIA という原則
ARIA の解説でほぼ必ず登場する言葉に「No ARIA is better than Bad ARIA(間違った ARIA を書くくらいなら、何も書かないほうがまし)」があります。これは大げさな標語ではありません。ARIA は要素の意味を上書きするため、間違えると、何も書かなかった場合よりも状況が悪化するからです。
そして WAI-ARIA の実装ガイドである ARIA in HTML/Using ARIA では、いちばん最初のルールとして次のことが挙げられています。必要な意味と挙動を最初から持っているネイティブの HTML 要素や属性があるなら、別の要素に ARIA を付けて代用するのではなく、そのネイティブ要素を使うこと。これが「ARIA の第一のルール」と呼ばれるものです。
典型例がボタンです。<div> に role="button" を付ければ、スクリーンリーダーは「ボタン」と読み上げてくれます。しかし読み上げが変わるだけで、ボタンとしての機能は何ひとつ付いてきません。
<!-- 読み上げは「ボタン」になるが、キーボードでは一切操作できない -->
<div role="button" onclick="submitForm()">送信</div>
この <div> は Tab キーでフォーカスできません。フォーカスできたとしても Enter や Space を押しても反応しません。disabled 属性も効かず、フォームの中に置いても送信されません。これらをすべて自前で実装すると、次のようになります。
<!-- div をボタンとして成立させるために必要な最低限 -->
<div role="button" tabindex="0" id="submit-btn">送信</div>
const btn = document.getElementById('submit-btn');
btn.addEventListener('click', submitForm);
// Enter と Space をキーボードで拾う必要がある
btn.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault(); // Space によるスクロールを止める
submitForm();
}
});
これでもまだ不十分で、無効化したいときは aria-disabled="true" を付けたうえでイベントハンドラー側でも処理を止める必要がありますし、フォーカスリングの見た目も自分で用意しなければなりません。同じことは、次の1行で終わります。
<!-- フォーカス・Enter/Space・disabled・フォーム送信がすべて標準で動く -->
<button type="submit">送信</button>
<button> の見た目が気に入らないという理由で <div> を選んでいるのであれば、その判断は逆です。ボタンの既定スタイルは CSS で完全に打ち消せます。appearance: none; と background: none;、border: none; を当ててしまえば、見た目上はただの箱になります。見た目のために意味を捨てる必要はありません。
HTML 要素が最初から持っている暗黙のロール
HTML の多くの要素は、何も書かなくてもロールを持っています。これを暗黙のロール(implicit role)と呼びます。<nav> は navigation、<button> は button、<a href="..."> は link、といった具合です。ブラウザーが仕様に従って自動的に割り当てるので、開発者が指定する必要はありません。
よく見かける <nav role="navigation"> や <main role="main"> という書き方は、暗黙のロールと同じロールを重ねて書いているだけで、意味はまったく増えません。古いブラウザー対応として広まった名残ですが、現在は不要です。主な要素の暗黙のロールを整理しておきます。
| HTML 要素 | 暗黙のロール | 補足 |
|---|---|---|
<button> | button | キーボード操作と disabled が標準で動く |
<a href="..."> | link | href が無い <a> はロールを持たない |
<nav> | navigation | role="navigation" の指定は不要 |
<main> | main | ページの主要コンテンツ |
<header> | banner | <article> や <section> の中に入ると generic になる |
<footer> | contentinfo | <header> と同じ条件が付く |
<aside> | complementary | 本文から切り離せる補足コンテンツ |
<section> | region | アクセシブルな名前が付いているときだけ |
<h1>〜<h6> | heading | 数字がそのまま見出しレベルになる |
<ul> / <ol> | list | 子の <li> は listitem |
<img alt="説明"> | img | alt="" のときは presentation になり読み飛ばされる |
<input type="checkbox"> | checkbox | チェック状態も自動で伝わる |
<input type="radio"> | radio | 同じ name でグループになる |
<dialog> | dialog | モーダル表示も標準で扱える |
<table> | table | <th> は columnheader / rowheader |
<div> / <span> | generic | 意味を持たない。だから ARIA が必要になりやすい |
表のいちばん下にある <div> と <span> だけが generic、つまり「何の意味も持たない」要素です。ページを <div> だらけで組み立てると、支援技術から見たページは平坦な文字の羅列になります。適切な要素を選ぶだけで、ARIA を1行も書かずにかなりの情報が伝わるのはこのためです。
role 属性を書くべき場面
では role の出番はいつかというと、HTML に対応する要素が存在しない UI を作るときです。ARIA には HTML には無い部品のロールが多数定義されており、代表的なものにタブ、ツリー、スライダー、ツールチップなどがあります。
たとえばタブ UI には対応する HTML 要素がありません。そのため、tablist / tab / tabpanel という3つのロールを組み合わせて表現します。
<div role="tablist" aria-label="設定">
<button role="tab" id="tab-general" aria-selected="true"
aria-controls="panel-general">一般</button>
<button role="tab" id="tab-account" aria-selected="false"
aria-controls="panel-account" tabindex="-1">アカウント</button>
</div>
<div role="tabpanel" id="panel-general" aria-labelledby="tab-general">
<p>一般設定の内容</p>
</div>
<div role="tabpanel" id="panel-account" aria-labelledby="tab-account" hidden>
<p>アカウント設定の内容</p>
</div>
ここで注目したいのは、タブそのものは <div> ではなく <button> に role="tab" を付けている点です。role で上書きするのはロールだけなので、<button> が持つキーボード操作やフォーカスの機能はそのまま残ります。ネイティブ要素の挙動を土台にしつつ、意味だけを付け替えるのが安全な書き方です。
なお、こうした複合的な部品は ARIA 属性を付けるだけでは完成しません。タブなら左右の矢印キーでの移動、ダイアログならフォーカスを内側に閉じ込める処理(フォーカストラップ)と Escape キーでの閉じる操作など、ロールごとに期待されるキーボード操作を JavaScript で実装する責任が発生します。ARIA は「そう名乗る」だけで、「そう振る舞わせる」ことはしてくれません。自信がない場合は、W3C の ARIA Authoring Practices Guide に載っている実装パターンをそのまま参考にするのが確実です。
aria-label と aria-labelledby で要素に名前を付ける
支援技術が要素を読み上げるとき、「役割」とセットで伝えられるのがアクセシブルネーム(accessible name)です。「検索、ボタン」の「検索」の部分がそれにあたります。通常は要素の中のテキストや <label>、画像の alt から自動的に決まりますが、目に見えるテキストが無い要素では名前が空になってしまいます。そこで使うのが aria-label と aria-labelledby です。
aria-label で文字列を直接指定する
aria-label は、名前にしたい文字列を属性値として直接書く方法です。アイコンだけのボタンが典型的な用途になります。
<!-- 中身がアイコンだけ。このままでは「ボタン」としか読まれない -->
<button type="button">
<svg width="24" height="24"><use href="#icon-close"></use></svg>
</button>
<!-- 名前を与える。「閉じる、ボタン」と読み上げられる -->
<button type="button" aria-label="閉じる">
<svg width="24" height="24" aria-hidden="true"><use href="#icon-close"></use></svg>
</button>
同じ考え方は、ランドマークの区別にも使えます。ページ内に <nav> が複数あると、支援技術のランドマーク一覧では「ナビゲーション」が並ぶだけでどれがどれか分かりません。名前を付ければ区別できます。
<nav aria-label="メインメニュー">...</nav>
<nav aria-label="パンくずリスト">...</nav>
注意点として、aria-label はどんな要素にでも効くわけではありません。ボタンやリンク、フォーム部品、ランドマークのようにロールを持つ要素では機能しますが、<div> や <span>、<p> のような generic のままの要素に付けても、多くの環境で無視されます。名前が読まれないときは、まずその要素がロールを持っているかを疑ってください。
aria-labelledby で画面上のテキストを参照する
aria-labelledby は、名前として使いたいテキストを持つ要素の id を指定する方法です。すでに画面に表示されている見出しなどを名前として流用できます。
<!-- ダイアログの名前として、中の見出しをそのまま使う -->
<div role="dialog" aria-modal="true" aria-labelledby="dialog-title">
<h2 id="dialog-title">記事を削除しますか?</h2>
<p>この操作は取り消せません。</p>
</div>
aria-labelledby は id をスペース区切りで複数書くこともでき、その場合は書いた順にテキストが連結されてひとつの名前になります。表の行ごとに同じラベルのボタンが並ぶようなケースで役に立ちます。
<tr>
<td id="row-12">CSS 入門</td>
<td>
<!-- 「削除 CSS 入門」という名前になり、どの行のボタンか分かる -->
<button type="button" id="del-12" aria-labelledby="del-12 row-12">削除</button>
</td>
</tr>
どちらが優先されるか
アクセシブルネームの決まり方には順番があります。強いほうから順に、aria-labelledby、aria-label、要素固有の仕組み(<label> の関連付け、<button> の中身のテキスト、<img> の alt など)、最後に title 属性、という順です。
<!-- 見えているテキストは「保存」だが、読み上げは「送信」になる -->
<button type="button" aria-label="送信">保存</button>
この例は上書きの強さを示すためのもので、実際には書いてはいけないコードです。目に見えるテキストと読み上げられる名前が食い違うと、音声入力ソフトで「保存をクリック」と言っても反応しなくなります。目に見えるテキストがある要素に aria-label を付けない、というのは実務でとくに守りたいルールです。名前を補いたいだけなら、既存のテキストを aria-labelledby で参照するほうが安全です。
aria-describedby で補足説明を結びつける
aria-describedby も id を参照する属性ですが、こちらは名前ではなく説明を与えます。名前が読み上げられたあとに、少し間を置いて読まれるのが一般的な挙動です。入力欄の入力条件やエラーメッセージを結びつける用途でよく使われます。
<label for="password">パスワード</label>
<input type="password" id="password" aria-describedby="password-hint">
<p id="password-hint">8文字以上で、英字と数字を含めてください。</p>
これで「パスワード、編集テキスト、8文字以上で、英字と数字を含めてください」のように、名前と説明が続けて伝わります。ヒントの文章が入力欄のすぐ下に表示されていても、支援技術は視覚的な位置関係を理解できないので、aria-describedby で明示的に結びつける必要があります。
入力エラーを伝える場合は、aria-invalid="true" と組み合わせます。エラーメッセージの要素を aria-describedby の参照先に加えると、フォーカスしたときにエラー内容まで読み上げられます。
<label for="email">メールアドレス</label>
<input type="email" id="email" aria-invalid="true"
aria-describedby="email-hint email-error">
<p id="email-hint">確認メールの送信先になります。</p>
<p id="email-error">メールアドレスの形式が正しくありません。</p>
参照先の要素が存在しない id だった場合、その参照は単に無視されます。エラーメッセージを JavaScript で動的に差し替えるときは、id が消えていないか確認してください。
aria-hidden=”true” で装飾を支援技術から隠す
aria-hidden="true" を付けた要素は、アクセシビリティツリーから取り除かれます。その要素の子孫もすべてまとめて対象になります。画面には表示されたまま、支援技術からだけ見えなくなる、という状態を作る属性です。
使いどころは、意味を持たない装飾です。ボタンの中のアイコン、見出しの横の飾り記号、CSS の代わりに置いた区切り文字などが該当します。先ほどの閉じるボタンの例で <svg> に aria-hidden="true" を付けていたのは、アイコンの中の図形が余計に読み上げられるのを防ぐためです。
<!-- 装飾のアイコンは隠し、テキストだけを読ませる -->
<p><span aria-hidden="true">★</span> おすすめの記事</p>
<!-- 隣にテキストがあるアイコンも、重複を避けるため隠す -->
<button type="button">
<svg width="16" height="16" aria-hidden="true"><use href="#icon-download"></use></svg>
ダウンロード
</button>
フォーカスできる要素には付けてはいけない
aria-hidden="true" でいちばん注意すべきなのがこれです。リンクやボタン、入力欄のようにキーボードでフォーカスできる要素に付けてはいけません。子孫にフォーカス可能な要素を含む要素に付けるのも同じく禁止です。
理由は、aria-hidden が消すのはアクセシビリティツリー上の存在だけで、フォーカスの順番からは消えないからです。その結果、Tab キーを押すとフォーカスはその要素に移動するのに、スクリーンリーダーは何も読み上げられない、という状態が生まれます。利用者から見ると、フォーカスがどこかに消えてしまったように感じられます。
<!-- 悪い例:Tab で到達できるのに、何も読み上げられない -->
<a href="/help" aria-hidden="true">ヘルプ</a>
<!-- 悪い例:中のボタンが同じ状態になる -->
<div aria-hidden="true">
<button type="button">閉じる</button>
</div>
要素を本当に無効化したいのであれば、aria-hidden ではなく、そもそも表示しない手段を選びます。display: none や visibility: hidden で消えた要素、HTML の hidden 属性が付いた要素は、フォーカスもできなくなり、アクセシビリティツリーからも外れます。二重に指定する必要はありません。
<!-- 見た目も支援技術も同時に隠れる。aria-hidden は不要 -->
<div hidden>
<button type="button">閉じる</button>
</div>
<!-- どうしても DOM に残したまま無効化するなら inert を使う -->
<div inert>
<button type="button">閉じる</button>
</div>
後半に出てくる inert は、要素とその子孫をフォーカスの対象から外し、クリックも受け付けなくする HTML の属性です。モーダルを開いたときに背景側を操作できなくする、といった用途にはこちらが適しています。
逆に、画面には見せずにスクリーンリーダーにだけ読ませたいという場合は、display: none を使ってはいけません。CSS で視覚的にだけ隠す、いわゆる「視覚的非表示」のクラスを用意します。
/* 見た目には出さないが、支援技術には読ませたいテキスト用 */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
状態を伝える属性で「いまどうなっているか」を知らせる
ここまでは要素の役割と名前の話でしたが、ARIA にはもうひとつ、変化する状態を伝える属性群があります。開いているのか閉じているのか、選択されているのか、いま何番目にいるのか、といった情報です。JavaScript で値を書き換えることが前提の属性なので、静的に書いて終わりではない点に注意してください。
| 属性 | 主な値 | 伝わる内容 |
|---|---|---|
aria-expanded | true / false | この要素が制御する領域が開いているか |
aria-current | page / step / true など | 同種の項目の中で、これが現在地であること |
aria-selected | true / false | タブやオプションが選択されているか |
aria-pressed | true / false | トグルボタンが押された状態か |
aria-checked | true / false / mixed | チェック状態(中間状態も表せる) |
aria-disabled | true / false | 操作できない状態か(見た目は変わらない) |
aria-live | polite / assertive / off | その領域の変化を読み上げるか、どの緊急度で読むか |
aria-controls | 対象要素の id | この要素がどこを制御しているか |
開閉メニューは aria-expanded を付け替える
ハンバーガーメニューやアコーディオンでは、aria-expanded を操作するボタン側に付けます。開かれる領域ではなくボタンに付ける、というのが間違えやすいところです。
<button type="button" id="menu-toggle"
aria-expanded="false" aria-controls="global-menu">
メニュー
</button>
<ul id="global-menu" hidden>
<li><a href="/">ホーム</a></li>
<li><a href="/about">このサイトについて</a></li>
</ul>
const toggle = document.getElementById('menu-toggle');
const menu = document.getElementById('global-menu');
toggle.addEventListener('click', () => {
// 現在の値を読み取って反転させる(属性値は文字列なので比較に注意)
const isOpen = toggle.getAttribute('aria-expanded') === 'true';
toggle.setAttribute('aria-expanded', String(!isOpen));
menu.hidden = isOpen; // 開いていたら隠す
});
これで、ボタンは「メニュー、折りたたみ、ボタン」「メニュー、展開、ボタン」のように状態込みで読み上げられ、押す前に開くのか閉じるのかが分かります。aria-expanded の値は必ず文字列の "true" / "false" であり、属性を削除した状態とは意味が違います。閉じているときは属性ごと消すのではなく、"false" を残しておいてください。
現在地は aria-current で示す
グローバルナビゲーションで現在のページを表す項目には、aria-current="page" を付けます。CSS のクラスで色を変えているだけでは、色を見られない利用者に現在地が伝わりません。
<nav aria-label="メインメニュー">
<ul>
<li><a href="/">ホーム</a></li>
<!-- いま表示しているページ。「現在のページ」と読み上げられる -->
<li><a href="/blog" aria-current="page">ブログ</a></li>
<li><a href="/contact">お問い合わせ</a></li>
</ul>
</nav>
この属性は CSS のセレクターとしても使えるので、状態と見た目を1か所で管理できます。クラス名と ARIA 属性を二重に管理しなくて済むのは実務上の利点です。
/* 属性セレクターで現在地のスタイルを当てる */
nav a[aria-current="page"] {
font-weight: bold;
border-bottom: 2px solid currentColor;
}
aria-current には page のほかに、入力ステップを表す step、パンくずの現在位置を表す location、カレンダーの今日を表す date、時刻の time、そして分類しにくいときの true が用意されています。同じ集合の中で現在地はひとつだけ、という点も守ってください。
あとから現れるメッセージは aria-live で読ませる
「保存しました」のようなメッセージを JavaScript で表示しても、フォーカスがそこに移らない限りスクリーンリーダーは何も言いません。画面の変化に気付いてもらうには aria-live を使います。
重要なのは、メッセージを入れる空の器を先に置いておくことです。aria-live を付けた要素をあとから DOM に追加しても、多くの環境では読み上げられません。
<!-- 中身は空でよい。ページ読み込み時から置いておく -->
<div id="status" role="status" aria-live="polite"></div>
// テキストを入れ替えると、その内容が読み上げられる
document.getElementById('status').textContent = '保存しました';
polite は「利用者の操作が一段落してから読む」、assertive は「いま読んでいる内容を中断してでも読む」という意味です。assertive は利用者の作業を遮るので、決済の失敗のように即座に伝えるべき内容だけに限ります。なお role="status" は aria-live="polite" 相当、role="alert" は aria-live="assertive" 相当の挙動を最初から持っているため、実際にはロールだけを書けば足りることも多いです。
意図どおりに読み上げられないときの調べ方
ARIA を書いても効いている実感がない、というのはよくあることです。見た目が変わらない以上、確認できる場所を知らないと手の打ちようがありません。ブラウザーの開発者ツールには、そのための機能が用意されています。
アクセシビリティツリーで実際の役割と名前を見る
Chrome の開発者ツールで要素を選び、Elements パネルの右側にある「Accessibility」タブを開くと、その要素の Computed Properties が表示されます。ここに出ている role と name が、支援技術に実際に渡っている値です。aria-label を書いたのに name が空のままなら、その指定は効いていません。
Firefox にはより見やすい「アクセシビリティ」パネルがあり、ページ全体のアクセシビリティツリーをそのまま木構造で確認できます。<div> ばかりのページを表示すると、ほとんどの要素が意味を持たない状態で並んでいることが一目で分かります。
アクセシブルネームの出どころを確認する
Chrome の Accessibility タブでは、名前がどこから来たのかも確認できます。Name の項目を展開すると、aria-labelledby による指定なのか、aria-label なのか、要素の中身から算出されたのかが示されます。名前が意図と違うときは、優先順位の高い属性がどこかで残っていないかを疑ってください。
あわせて確認したいのが、参照先の id です。aria-labelledby や aria-describedby は id の綴りが1文字違うだけで黙って無視されます。コンポーネントを繰り返し描画していて id が重複している場合も、最初に見つかった要素だけが参照され、意図しない名前になります。動的に生成する id は必ず一意になるようにしてください。
キーボードだけで操作してみる
ツールで確認する前にできる、いちばん効果的な検証がこれです。マウスから手を離し、Tab キーだけでページを一巡してみてください。role="button" を付けた <div> はここで素通りされますし、aria-hidden を付けた要素にフォーカスが入って行方不明になる問題も、この方法ならすぐ見つかります。ARIA の不備の多くは、読み上げを聞く前に、キーボード操作の段階で表面化します。
まとめ
WAI-ARIA は、要素の役割・名前・状態を支援技術に伝えるための属性群で、書いてもページの見た目や動作は一切変わりません。書き換えられるのはアクセシビリティツリーの中身だけです。role 属性は要素のロールを上書きしますが、ほとんどの HTML 要素は暗黙のロールを持っているため、<nav role="navigation"> のような指定は不要ですし、<div role="button"> のようにネイティブ要素の代用として使うのは避けるべきです。<button> を使えばフォーカス・Enter/Space・disabled がすべて標準で動きます。名前を補うときは、テキストを直接書く aria-label と、画面上のテキストを参照する aria-labelledby を使い分け、見えているテキストと読み上げが食い違わないように気を付けてください。補足説明は aria-describedby、装飾を隠すのは aria-hidden="true" ですが、フォーカスできる要素に aria-hidden を付けてはいけません。状態を伝える aria-expanded や aria-current は JavaScript で値を更新して初めて意味を持ちます。困ったときは開発者ツールのアクセシビリティツリーで実際のロールと名前を確認し、まずはキーボードだけで操作できるかを試すのが近道です。