Webアクセシビリティ実装の優先順位:セマンティックHTML→キーボード→ARIAの順で直す
a11y対応は「最後にaxe」では遅い。セマンティックHTML優先、ARIAは最後、キーボードとフォーカス、コントラスト、実機確認まで実装順に解説するハブ記事。
「アクセシブルにしといて」。そう頼んだら、AIは全部のdivにroleとaria-labelを生やして返してきました。axeのスコアは満点。でもキーボードのTabキーで送信ボタンにたどり着けない。マウスを取り上げた瞬間、そのフォームは誰にも使えない箱になっていました。
これ、AIだけの話じゃありません。僕も昔、同じ順番で間違えていました。アクセシビリティ対応を「最後にaxeを流して赤い行を消す作業」だと思っていたんです。
順番が逆なんですよ。a11y(アクセシビリティの略)は、最初のHTMLタグを何にするかで八割が決まります。buttonをbuttonと書くだけで、キーボードもスクリーンリーダーも勝手に動く。ARIAはその上に、足りないぶんだけ薄く塗る最後の道具です。今日はその「直す順番」を、コピペで動くコードと一緒に書きます。
この記事の要点
- アクセシビリティは「最後にaxe」ではなく、HTMLタグ選びの段階でほぼ決まる。直す順番は セマンティックHTML → キーボード操作 → フォーカス管理 → コントラスト → ARIA。
- ARIAは最後の手段。ネイティブHTML(
button/a/label/nav)で表せる意味を、わざわざARIAで再発明しない。MDNも公式にそう言っている。 - 自動検査(axe)が拾えるのは全体の3〜4割。キーボードとスクリーンリーダーの手動確認で残りを埋める。
- 個別UI(モーダル、トースト、フォーム、ショートカット、パンくず)には固有の落とし穴がある。各論は記事末尾の内部リンクへ。
- 基準はW3CのWCAG 2.2、実装パターンはWAI-ARIA APG、ARIAの考え方はMDNのARIAガイドを見れば足りる。
「最後にaxe」だとなぜ手遅れなのか
axeのような自動検査ツールは優秀です。でも万能じゃない。Deque自身が公開しているデータでも、自動検査で見つかるアクセシビリティ問題は全体のおよそ3〜4割。残りの6割は機械には判定できません。
たとえば「リンクの文言が文脈に合っているか」。<a href="/products">こちら</a> は構文上なんの問題もありません。axeは何も言いません。でもスクリーンリーダーでリンクだけ拾い読みする人にとって、「こちら」は行き先のわからない迷子のリンクです。
「最後にaxe」が手遅れなのは、赤い行が消えても、本当に使えるかは別問題だから。しかも完成間際にHTMLの土台から直そうとすると、レイアウトもCSSも壊れて、結局「とりあえずARIAで蓋をする」最悪の応急処置に走りがちです。
だから僕は順番を固定しました。下の表が、その「直す順番」と各段階の合格ラインです。上から順に潰すと、ARIAの出番はほとんど残りません。
| 順番 | 領域 | 最低限の合格ライン | やりがちな事故 |
|---|---|---|---|
| 1 | セマンティックHTML | button a form label main nav h1〜を意味どおりに使う | クリックできるdivを量産する |
| 2 | キーボード操作 | Tab・Shift+Tab・Enter・Space・Escで主要操作が完結する | マウスでしか閉じられないモーダル |
| 3 | フォーカス管理 | 開いたUIに焦点が入り、閉じたら呼び出し元のボタンに戻る | 焦点がページ裏側へ逃げる |
| 4 | コントラスト・状態 | WCAG AA相当の色比と、色以外の手がかりを併用 | 赤文字「だけ」でエラーを示す |
| 5 | ARIA | ネイティブHTMLで足りないときだけ補う | aria-labelでラベル不足をごまかす |
| 6 | 検査 | axe(自動)とキーボード・スクリーンリーダー(手動)の両方 | axeゼロ件だけで合格にする |
「ARIAは補助であり、土台ではない」。これだけ覚えて帰ってもらえれば、この記事の半分は伝わったことになります。
ステップ1:タグを正しく選ぶだけで八割終わる
a11yで一番効くのに一番地味なのが、ここです。ネイティブのHTML要素は、キーボード操作・フォーカス・スクリーンリーダー対応を最初から内蔵しています。タダで付いてくる装備を、自分で再実装する必要はありません。
よく見る「動くけど壊れている」CTAカードがこれです。マウスでは遷移しますが、キーボードでは選べず、スクリーンリーダーは何のリンクかも読めません。
<div class="hero-card" onclick="location.href='/products'">
<div class="title">Claude Code テンプレート集</div>
<div class="button">今すぐ見る</div>
</div>
直し方はシンプル。カード全体を無理にクリック領域にしない。見出しは見出しタグに、遷移はリンクタグに戻すだけです。
<section aria-labelledby="cta-heading" class="product-cta">
<h2 id="cta-heading">Claude Code テンプレートでレビューを短縮する</h2>
<p>実装・レビュー・デバッグで使うプロンプトを、コピペできる形でまとめています。</p>
<a class="primary-link" href="/products">教材一覧を見る</a>
</section>
判断基準はひとつだけ。別の場所へ移動するならa、その場の状態を変えるならbutton。「見た目はボタンだけどクリックすると別ページに飛ぶ」ものはリンクです。逆に「モーダルを開く」「メニューを展開する」はボタン。ここを取り違えると、右クリックで「新しいタブで開く」が出なかったり、Enterで反応しなかったりと、地味な不便が積み重なります。
divにonclickを付けてrole="button"とtabindex="0"を足し、さらにEnterとSpaceのキーハンドラを書く……あの一連の苦労は、<button>と一文字書けば全部消えます。
ステップ2:マウスを抜いて操作してみる
僕がチームに必ずやらせるテストがあります。マウスをデスクの引き出しにしまって、キーボードだけで主要導線を最後まで操作する。トップを開いて、フォームを送って、モーダルを開閉して、メニューをたどる。これをTab・Shift+Tab・Enter・Space・Escだけでやる。
途中で「あれ、ここから進めない」が出たら、そこがバグです。だいたいステップ1のタグ選びをサボった箇所で詰まります。
キーボード操作で押さえる最低ラインは4つ。
- Tab / Shift+Tab で、操作できる要素を順送り・逆送りできる
- Enter でリンクとボタンが発火する(
aとbuttonなら自動) - Space でボタンとチェックボックスが発火する(
buttonなら自動) - Esc で開いているモーダル・メニュー・ポップオーバーが閉じる
ショートカットキー(Cmd/Ctrl+Kでコマンドパレットを開く等)を足すなら、入力欄にフォーカスがあるときは無効化する、といった配慮が要ります。このあたりの判定ロジックはWebアプリのキーボードショートカット実装で実コード付きにまとめました。
ステップ3:フォーカスを迷子にしない
キーボードで動かせるようになったら、次は「今どこにいるか」の管理です。フォーカス(焦点)は、キーボードユーザーにとってのマウスカーソルそのもの。これが見えなくなったり、変な場所へ飛んだりすると、画面の中で完全に遭難します。
特に事故が多いのが3つ。
outline: noneでフォーカスリングを消す。デザイナーに「枠が出るのが嫌」と言われて消すやつです。代わりの表示を用意せずに消すと、キーボードユーザーは現在地を失います。- モーダルを開いても焦点が中に入らない。背景のリンクにフォーカスが残ったまま、見えないモーダルの裏側をTabで彷徨う羽目になります。
- モーダルを閉じた後、焦点がページ先頭へ飛ぶ。本来は「開くきっかけになったボタン」に戻すべきです。
3番が地味に厄介で、僕も長いこと気づきませんでした。開閉のたびにスクリーンを上から読み直しになるので、リスト操作などでは致命的です。モーダル特有のフォーカストラップとdialog要素の落とし穴はReactモーダルのアクセシビリティに分けて書いたので、UIを実装する人はそちらも。
フォーカスを「常に見える」状態に保つCSSの最低ラインがこれです。:focus-visibleを使えば、マウス操作時には出さず、キーボード操作時だけリングを出せます。
.primary-link {
background: #0f766e;
border-radius: 6px;
color: #ffffff;
display: inline-flex;
font-weight: 700;
gap: 0.5rem;
min-height: 44px; /* WCAG 2.2 のターゲットサイズ目安 */
padding: 0.75rem 1rem;
}
/* マウス時は出さず、キーボード操作時だけフォーカスリングを表示 */
.primary-link:focus-visible,
button:focus-visible,
input:focus-visible {
outline: 3px solid #f59e0b;
outline-offset: 3px;
}
/* エラーは色だけに頼らず、左罫線と記号でも示す */
.field [role="alert"] {
border-left: 4px solid #b91c1c;
color: #7f1d1d;
margin-top: 0.5rem;
padding-left: 0.75rem;
}
ついでにmin-height: 44pxも入れておきます。WCAG 2.2でターゲットサイズの基準が追加されたので、指で押す前提のタップ領域を確保しておくと、モバイルでの「押せない」が減ります。
ステップ4:色と状態は「色だけ」に頼らない
コントラスト不足は、axeが比較的よく拾ってくれる領域です。それでも実装中に退化しやすい。薄いグレーの注釈、ホバーしないと出ない状態表示、背景に溶ける薄色ボタン。デザインカンプ上はおしゃれでも、屋外のスマホ画面では読めません。
WCAG AAの目安は、通常テキストでコントラスト比4.5:1以上、大きい文字で3:1以上。ブラウザのデベロッパーツールでもチェックできるので、迷ったら確認します。
もうひとつ大事なのが色「だけ」で情報を伝えないこと。「赤い欄がエラーです」は、色覚特性のある人や白黒印刷では伝わりません。アイコン、テキスト、罫線など、色以外の手がかりを必ず添えます。上のCSSでエラーに左罫線を足したのは、まさにこのためです。
ステップ5:ARIAは「足りないぶんだけ」薄く塗る
ここまで来て、初めてARIAの出番です。順番を守ると、塗る面積は驚くほど小さくなります。
ARIAの第一原則は、MDNのARIAガイドにもはっきり書いてあります。必要な意味と挙動を持つネイティブHTMLがあるなら、ARIAで置き換えるより、そのHTMLを使えと。<button>があるのに<div role="button">を書くのは、車があるのにわざわざ車輪から組み立てるようなものです。
ARIAが本当に要るのは、ネイティブHTMLに対応する部品がない場面です。たとえば:
- 動的な通知を読み上げさせたい →
aria-live(トースト通知などで使う。各論はReactトースト通知の実装へ) - 現在地を示したい → ナビの現在ページに
aria-current="page"(パンくずでの使い方はパンくずリストをReactで実装に) - 入力エラーを入力欄に結びつけたい →
aria-invalidとaria-describedby - モーダルである宣言 →
role="dialog"とaria-modal="true"
逆に、見出しが足りないからとaria-labelで名前を付け足したり、ラベルをサボってplaceholderで代用したりするのは、典型的なアンチパターンです。それは「土台の手抜きをARIAで隠す」行為で、保守する人を確実に泣かせます。
コピペで動く:アクセシブルなフォーム
理屈より動くものです。問い合わせ・資料請求・購入前相談のフォームは、a11yと売上が直結します。ラベルがない、エラーが赤文字だけ、送信後どこを直せばいいかわからない——これ全部、離脱の原因です。
下はReactでそのまま試せる最小フォーム。ステップ1〜5を一通り入れてあります。ラベルと入力欄をhtmlFor/idで結び、エラーはaria-invalidとaria-describedbyで読み上げ可能にし、role="alert"で発生を即通知します。
import { FormEvent, useState } from "react";
type Errors = { name?: string; email?: string };
export function ConsultationForm() {
const [errors, setErrors] = useState<Errors>({});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
const next: Errors = {};
// 簡易バリデーション。本番では zod 等で二重化したい
if (!String(data.get("name") || "").trim()) {
next.name = "お名前を入力してください。";
}
if (!String(data.get("email") || "").includes("@")) {
next.email = "メールアドレスを確認してください。";
}
setErrors(next);
}
return (
<form aria-labelledby="form-title" onSubmit={handleSubmit} noValidate>
<h2 id="form-title">導入相談フォーム</h2>
<div className="field">
{/* label と input を htmlFor / id で結ぶ。これが土台 */}
<label htmlFor="name">お名前</label>
<input
id="name"
name="name"
autoComplete="name"
aria-invalid={errors.name ? "true" : "false"}
aria-describedby={errors.name ? "name-error" : undefined}
/>
{/* エラーは発生時だけ描画し、role="alert" で即読み上げ */}
{errors.name && (
<p id="name-error" role="alert">{errors.name}</p>
)}
</div>
<div className="field">
<label htmlFor="email">メールアドレス</label>
{/* 補足説明は常時表示。エラーとは別の id で持つ */}
<p id="email-help">返信できる業務用メールを入力してください。</p>
<input
id="email"
name="email"
type="email"
autoComplete="email"
aria-invalid={errors.email ? "true" : "false"}
aria-describedby={
errors.email ? "email-help email-error" : "email-help"
}
/>
{errors.email && (
<p id="email-error" role="alert">{errors.email}</p>
)}
</div>
<button type="submit">相談内容を送信する</button>
</form>
);
}
ここでハマりやすいのがaria-describedbyの扱いです。エラー要素が存在しないのにaria-describedby="email-error"を常に付けっぱなしにすると、存在しないIDを参照する宙ぶらりんの状態になり、保守時に混乱します。補足説明は常時、エラーは発生時だけ。上のコードのように参照を出し分けるのが安全です。検証ロジック自体を堅くしたいならreact-hook-form × zodでフォーム検証を二重化するも合わせてどうぞ。
仕上げ:axeで自動検査、人間で手動確認
最後に検査です。順番は「機械で広く、人間で深く」。
まずaxeをCIに入れて、構造的な問題(ラベル抜け、コントラスト不足、ランドマーク欠落など)を自動で弾きます。PlaywrightとAxeBuilderを使うと、テストとして回せます。
npm install -D @axe-core/playwright @playwright/test
npx playwright install --with-deps chromium
import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";
test("相談フォームに重大なアクセシビリティ違反がない", async ({ page }) => {
await page.goto("/contact");
const results = await new AxeBuilder({ page })
.include("main")
.withTags(["wcag2a", "wcag2aa", "wcag22aa"])
.analyze();
// 重大な違反はゼロを保証。文言の分かりやすさは別途、人間が確認する
expect(results.violations).toEqual([]);
});
そのうえで、人間がキーボードとスクリーンリーダーで確認します。WindowsならNVDA、macOSならVoiceOverが手軽です。全ページを完璧に読む必要はありません。CTA・フォーム・モーダル・ナビ・エラー表示という、収益と離脱に近い場所から優先します。
検査を毎回の作業に組み込みたいなら、コミット前後にコマンドを自動実行する仕組みが便利です。判断は人間に残しつつ手間だけ減らす形については、Claude Code Hooks入門に実務的な組み込み方を書きました。
公開前は、この10項目をざっと見ます。
- 見出しが
h1から自然に階層化されている - クリックできる要素が
buttonまたはaになっている - Tabだけで主要導線を最後まで操作できる
- フォーカス位置が常に見える
- モーダルを開閉して焦点が呼び出し元へ戻る
- フォームのラベル・説明・エラーが読み上げられる
- 色だけに依存した状態表示がない
- axe(または同等ツール)の重大違反がゼロ
- スクリーンリーダーでCTAとエラーの意味が伝わる
- 変更範囲外の文言・価格・計測イベントを触っていない
よくある質問
Q. ARIAをたくさん付ければアクセシブルになりますか?
逆です。ARIAは付けるほどリスクが上がります。間違ったroleや宙ぶらりんのaria-describedbyは、何もしないより読み上げを悪化させます。「No ARIA is better than bad ARIA(下手なARIAなら無いほうがマシ)」はW3Cでも語られる原則です。まずネイティブHTMLで土台を固め、足りないぶんだけ薄く塗ってください。
Q. axeで違反ゼロなら、もう対応完了ですか? いいえ。axeが拾えるのは全体の3〜4割で、リンク文言の適切さや読み上げ順、業務文脈に合ったラベルは判定できません。違反ゼロは「最低ラインを越えた」だけ。最後はキーボードとスクリーンリーダーで人間が触って確認します。
Q. WCAGのAとAAとAAA、どれを目指せばいいですか? 実務の現実的な目標はAAです。多くの公的・企業ガイドラインもAAを基準にしています。AAAは項目によっては達成が難しく、すべてに適用するのは現実的でないとWCAG自身も述べています。まずAAを満たし、重要な画面だけ部分的にAAAを狙うのが堅実です。
Q. 既存サイトに後付けする場合、どこから手を付けるべき? 全ページを一度に直そうとすると挫折します。収益と離脱に直結する順、つまりCTA・フォーム・モーダル・グローバルナビ・エラー表示から着手してください。そして直す中身も、この記事の順番(HTML→キーボード→フォーカス→色→ARIA)で潰すのが最短です。
Q. デザイナーにフォーカスリングを消すよう言われました。どうすれば?
「消す」ではなく「キーボード操作時だけ出す」で折り合えます。:focus-visibleを使えば、マウスクリック時にはリングを出さず、Tab移動時だけ出せます。本記事のCSS例がそのまま使えます。見た目の要望とアクセシビリティは、ここでは両立できます。
実際に試した結果
この記事の更新では、僕(Masa)の実務でよく見る3つの事故——「フォームのエラーは見えているのに読み上げられない」「モーダルを閉じた後にフォーカスがページ先頭へ飛ぶ」「CTAカードがdivクリックになっている」——を題材に、直す順番を固定して検証しました。
結論はいつも同じでした。HTMLのタグ選びを最初に直すと、後段のARIAがほとんど要らなくなる。逆に「最後にaxeで蓋」を続けていた頃は、赤い行は消えてもキーボードでは詰まったまま、ということが何度もありました。axeはあくまで網。最後にマウスを引き出しにしまって自分の指で触る、この一手間が、いちばん多くの「使えない」を見つけてくれます。
各UIコンポーネントの具体的な実装は、本文中の内部リンク(モーダル / トースト / キーボードショートカット / パンくず / フォーム検証)に分けて書いています。土台はこの記事で、各論はそちらで固めてください。
繰り返し使うレビュー文面や、チームでの運用ルール化に進みたい方は、Claude Code教材一覧も覗いてみてください。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Codeに1ファイルだけ直させる指示文のつくり方
「もっと良くして」で40行も変えられた失敗から学んだ、触る範囲・検証・戻し方をセットにしたClaude Code用の依頼文テンプレートを紹介します。
Claude Code の権限拒否から復旧する: 止まった理由を次の安全手順に変える
Claude Code のコマンドが拒否されたとき、焦って許可を広げずに、拒否理由、代替手順、証拠コマンド、再試行条件へ分解する方法。
Claude Codeにビルド→スモークテスト→自動修正を回させる足場の作り方
最小スモークテストの選び方、失敗ログを食わせて直させるループ、回数上限と確認ゲートで暴走を止める方法を、コピペで動くコード付きで紹介します。