自社サイトのLighthouseは、アクセシビリティのスコアが92で安定していました。数字だけ見れば「まあ緑だし、大きな問題はないだろう」と思っていたんですよね。
きっかけは、ある日たまたまマウスのレシーバーを差し忘れて、トラックパッドも反応しない状態でPCを触っていたことでした。せっかくなのでTabキーだけで自社サイトの問い合わせフォームまでたどり着けるか試してみたら、これが全然ダメだったんです。
正直なところ、スコアが緑だったので油断していました。今思えば、あの92点は「機械で測れる部分」しか見ていなかった、というだけの話でした。
自動チェックが緑でも安心できない理由
まず前提として、axeやLighthouseのアクセシビリティチェックはとても優秀です。コントラスト比、画像の代替テキスト、フォームのラベル付け、aria-*属性の付け忘れ——このあたりの機械可読な問題は、かなりの割合で拾ってくれます。実際、自社サイトも過去にここを一度整備していて、その名残でスコアが高かったわけです。
ただ、いろいろな記事を読んで回って腑に落ちたのは、自動チェックで見つけられるのは全体の問題のうち一部でしかない、ということでした。ツールによって「2〜3割」とも「6〜7割」とも言われますが、いずれにせよ残りは手を動かして確認するしかない領域です。
具体的に、自動チェックが苦手なのはこのあたりでした。
| 観点 | 自動チェック | 手で見るしかない |
|---|---|---|
| コントラスト・alt・ラベル関連づけ | ◎ よく拾う | — |
| aria属性の付け忘れ・値の矛盾 | ◎ よく拾う | — |
| Tabの移動順序が自然か | △ ほぼ無理 | キーボードで縦断 |
| 開閉メニューを閉じた後のフォーカス挙動 | × 検知しない | キーボードで縦断 |
| エラーや状態変化の読み上げタイミング | × 検知しない | スクリーンリーダー |
つまり、aria-labelやaria-expandedを付けただけでは「操作できる」ことは保証されないんですよね。属性はあくまで補助輪で、実際にキーボードで動かせるかは別の話でした。ここを勘違いしていたのが今回の反省点です。
Tabキーだけで歩いてみて詰まったところ
一番わかりやすく詰まったのが、スマホ幅で出てくるハンバーガーメニューでした。自社サイトのナビは、こんな感じのスクリプトで開閉していました。
// 当時のNav.astro(抜粋)
const btn = document.querySelector('.nav-hamburger');
const links = document.querySelector('.nav-links');
btn.addEventListener('click', () => {
const expanded = btn.getAttribute('aria-expanded') === 'true';
btn.setAttribute('aria-expanded', String(!expanded));
links.classList.toggle('is-open', !expanded);
});
// メニュー外クリックで閉じる
document.addEventListener('click', (e) => {
if (!btn.contains(e.target) && !links.contains(e.target)) {
btn.setAttribute('aria-expanded', 'false');
links.classList.remove('is-open');
}
});
aria-expandedはちゃんと切り替わるし、aria-controlsも付いていたので、axe的には何も文句を言われません。ドキュメントにはこう書いてあるけど実際やってみると、という典型でした。
問題は2つありました。ひとつは、閉じている状態のメニューリンクにフォーカスが吸い込まれること。.is-openが付いていないとき、CSS的には画面外に飛ばしていただけでdisplay:noneにはしていなかったので、リンク自体はキーボードのフォーカス対象として生き残っていたんです。Tabを押していくと、見えていないメニュー項目に順番にフォーカスが移り、フォーカスリングだけが画面のどこか外にある——という状態になっていました。マウスなら絶対気づかない挙動ですね。
もうひとつは、開いたメニューがEscで閉じないこと。外クリックでは閉じるのに、キーボードだけの人には閉じる手段がありませんでした。
直したのがこちらです。
// 改善後:キーボードで本当に閉じられる/隠れた要素はフォーカス対象から外す
const btn = document.querySelector('.nav-hamburger');
const links = document.querySelector('.nav-links');
function setOpen(open) {
btn.setAttribute('aria-expanded', String(open));
links.classList.toggle('is-open', open);
// 閉じているときはメニュー内をフォーカス不可にする(画面外リンクへの迷子を防ぐ)
links.toggleAttribute('inert', !open && isMobile());
if (open) links.querySelector('a')?.focus(); // 開いたら中へフォーカスを移す
}
btn.addEventListener('click', () => setOpen(btn.getAttribute('aria-expanded') !== 'true'));
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && btn.getAttribute('aria-expanded') === 'true') {
setOpen(false);
btn.focus(); // 閉じたら開閉ボタンにフォーカスを戻す
}
});
inert属性を使うと、その要素の中身をまるごとフォーカス・クリックの対象から外せます。CSSで見えなくするのとは別に、キーボードやスクリーンリーダーからも確実に「無いもの」にできるのが良いところでした。閉じたときにトグルボタンへフォーカスを戻す一手間も、地味ですが体験がだいぶ変わります。
ちなみにCSS側も、transformで画面外に飛ばすだけの隠し方をやめて、閉じているときはvisibility:hiddenを併用するように直しました。「見えない=操作できない」を、見た目と挙動の両方で揃えるイメージです。
フォームは属性が付いていた。それでも足りなかった
問い合わせフォームのほうは、正直よくできていました。過去の自分(か誰か)が頑張っていて、各入力欄にはlabelとfor/idの関連づけがあり、エラー文言にはaria-live="polite"、送信全体のエラーにはaria-live="assertive"、選択肢のグループにはrole="radiogroup"とaria-requiredまで付いていたんです。ここは素直に助かりました。
<label class="form-label" for="f-email">メールアドレス<span class="req">*</span></label>
<input id="f-email" type="email" ... />
<p x-show="errors.email" x-text="errors.email"
class="form-error" aria-live="polite"></p>
ただ、実際にキーボードとスクリーンリーダーで送信までやってみると、まだ穴がありました。バリデーションエラーで送信が止まったとき、エラーは表示されるのにフォーカスがそこに移動しないんです。目で見ている人はエラーの赤に気づけますが、キーボードだけの人は「送信ボタンを押したのに何も起きない」ように感じてしまいます。aria-liveは「その領域が更新されたら読む」だけなので、フォーカスまでは動かしてくれないんですよね。
なので、送信をブロックしたら最初のエラー欄へフォーカスを飛ばすようにしました。
// 送信バリデーションで止めたら、最初のエラー欄へフォーカスを移す
function focusFirstError(errors) {
const order = ['consultationType', 'name', 'company', 'email', 'message'];
const firstKey = order.find((k) => errors[k]);
if (!firstKey) return;
const el = document.getElementById(`f-${firstKey}`)
?? document.querySelector(`[data-field="${firstKey}"]`);
el?.focus();
el?.scrollIntoView({ block: 'center', behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
}
ここは正直まだ改善の余地があります。単一ページのフォームならこれで十分なんですが、自社のフォームは3ステップに分かれていて、ステップをまたいだときのフォーカス復帰までは自動テストで担保できていません。今はステップ切り替えのたびに手でTab縦断して確認している状態で、ここはPlaywrightあたりで自動化したいと思っています。
下地の部分でやり残していたこと
個別の部品を直したあと、サイト全体の土台としてやり残していたことにも気づきました。細かいですが、効くやつです。
- スキップリンクが無かった:ページ先頭に「メインコンテンツへスキップ」のリンクを置いておくと、毎回ナビを全部Tabで飛ばさずに本文へ行けます。キーボード利用者の移動コストが一気に下がるので、これは入れておくべきでした。
:focus-visibleが一部にしか当たっていなかった:フォームやボタンには付けていたのに、ナビのリンクには効いていなくて、フォーカスリングが出たり出なかったりしていました。全体で一貫させるようにしました。- ランドマークと見出し階層:
header/nav/main/footerできちんと領域を分け、h1からの見出しの飛びをなくす。スクリーンリーダーはこの構造を頼りに拾い読みするので、地味に効きます。 prefers-reduced-motion:一部のコンポーネントでは対応していたものの、今回追加したスクロール移動などにも忘れず入れました。
装飾目的の画像はalt=""で読み飛ばさせ、意味のある画像だけaltを書く、といった基本も改めて棚卸ししました。ここは自動チェックが得意な領域なので、CIに乗せておくと楽です。
受け入れ基準に「Tab縦断」を足した
今回いちばん変えたのは、コードよりもチェックの回し方でした。これまでは「Lighthouseのアクセシビリティが緑ならOK」を暗黙の受け入れ基準にしていたのですが、それだと今回のような穴はまるごと素通りしてしまいます。
なので、フォーム系やナビをいじるPRには、次の手順を受け入れ基準に足しました。
- axe(またはLighthouse)で機械可読な問題をゼロにする(ここは自動)
- マウスを触らずTabだけで、主要導線を端から端まで通す(開閉メニュー・モーダル・フォームの送信まで)
- スクリーンリーダーで、エラーや状態変化がちゃんと読み上げられるか確認する
優先度の付け方もシンプルにしました。「その操作ができない人がいる」ものは最優先で即修正、「できるけど著しく不便」はリリース前まで、「代替手段はあるが不便」は段階的に、という三段階です。今回のハンバーガーがEscで閉じない件やフォーカス迷子は、キーボードだけの人が実質操作不能になるので、迷わず最優先で潰しました。
振り返って
やってみて一番刺さったのは、「aria属性を付ける」ことと「キーボードで操作できる」ことは、重なってはいるけど別物だ、という当たり前の事実でした。属性はスクリーンリーダーに意味を伝えるためのもので、操作できるかどうかはフォーカスの流れをちゃんと設計できているかにかかっている。スコアはあくまで入り口の目安で、最後は自分の指でTabを叩いて確かめるしかないんですよね。
今後は、新しく作るインタラクティブな部品には最初からフォーカス管理を組み込んでおいて、「あとで直す」を減らしていきたいです。ラボの数字だけで満足しない癖を、チームの受け入れ基準として残せたのは今回の収穫でした。
もっと良い進め方や、見落としがちなポイントがあったら教えてください。