Claude CodeでSPAの画面遷移後のフォーカスと読み上げを直す
SPAでリンクを押しても、スクリーンリーダーが何も読まず、フォーカスも元の位置に残る問題を、Claude Codeに直させる手順です。React RouterとNext.jsの実装例と、Playwrightで状態を検証するテストを載せます。
SPA(シングルページアプリケーション)では、リンクを押してもブラウザーはページを読み込み直しません。画面の中身だけが入れ替わるため、スクリーンリーダー利用者には「何も起きなかった」ように聞こえることがあります。キーボード利用者は、押したリンクが消えた後もフォーカスが宙に浮いたままになります。
この記事では、Claude Codeに現状を再現するテストを先に書かせ、タイトル更新、見出しへのフォーカス移動、live regionの置き方の3点を直させる流れを示します。例はReact RouterとNext.jsのApp Routerです。
画面遷移で壊れる3つのこと
ページ全体を読み込む通常の遷移では、ブラウザーと支援技術がタイトルを読み上げ、フォーカスも文書の先頭に戻ります。Next.jsのアクセシビリティ解説にも、サーバー描画のページ遷移では「ページ読み込み時にスクリーンリーダーがタイトルを読み上げる」と書かれています。クライアント側の遷移ではこの動きが起きません。
SPAで自前の対処が要るのは、次の3つです。
遷移後に確かめる3項目
タイトル
document.titleが遷移先の内容に変わっているか。同じタイトルのままだと、履歴やタブ一覧でも区別がつきません。フォーカス
押したリンクが画面から消えた後、フォーカスがどこにあるか。見出しなど、新しい内容の入口に移っているかを見ます。
通知
遷移した事実が、画面を見ない人にも伝わっているか。フォーカス移動か、live regionのどちらかで伝えます。
3つは独立した問題ではありません。フォーカスを新しい見出しに動かせば、通知も同時に片付くことが多いからです。ここを取り違えると、live regionを足したのに二重に読まれる、という失敗につながります。
フォーカス移動とlive regionの使い分け
WCAG 2.2の達成基準4.1.3(ステータスメッセージ)の解説には、対象外になる場合が明記されています。ダイアログのようにフォーカスを奪う変化は「コンテキストの変更」にあたり、支援技術がすでに伝えるので、この基準の範囲外です。基準が扱うのは、フォーカスを取らずに内容が追加される変化です。
この線引きから、ルート遷移の扱いは次のように分かれます。
| 起きること | 伝え方 | 例 |
|---|---|---|
| 画面の主題が変わる(ルート遷移) | 伝え方見出しにフォーカスを移す | 例「料金プラン」ページへ移動 |
| フォーカスを取らず内容が変わる | 伝え方role="status" のlive region | 例「18件見つかりました」 |
| 即時に知らせるべき失敗 | 伝え方role="alert" など | 例送信エラー |
解説の例にも、検索後の「5 results returned」や「Your form was successfully submitted.」のような短い文が並んでいます。ページ全体の入れ替えではなく、画面の一部に起きる結果の通知が対象です。
ルート遷移は表の1行目に当たります。ルート遷移のたびにlive regionへ「〇〇ページに移動しました」と書き込む実装もありますが、Next.jsはすでに独自のアナウンサーを持っています。その仕組みを先に見ます。
Next.jsが読み上げる範囲と、足りないもの
Next.jsはクライアント側遷移を支援技術に伝えるため、ルートアナウンサーを標準で備えています。読み上げる名前は、まず document.title、次に <h1> 要素、最後にURLのパス名の順で探します。ドキュメントは、ページごとに一意で説明的なタイトルを付けるよう求めています。
この記述から言えるのは、通知の質がタイトルの質で決まることです。タイトルが全ページ同じなら、アナウンスも毎回同じ文言になります。一方、アクセシビリティ解説にはフォーカス移動の記述がありません。フォーカスを動かす処理は、アプリ側で補います。
Next.jsでは静的なタイトルを metadata で定義し、動的なものは generateMetadata 関数で返します。
// app/pricing/page.tsx
export const metadata = { title: "料金プラン | Example" };React Routerの例では、タイトル更新とフォーカス移動の両方をアプリ側で用意する前提で組みます。
手順1: 現状を再現するテストを先に書かせる
直す前に、壊れていることをテストで示します。Claude Codeには次のように頼みます。
SPAのルート遷移後の状態をPlaywrightで検証するテストを書いてください。
まだ実装は直さず、現状で落ちることを確認します。検証は3点です。
1. 遷移後に document.title が遷移先の内容に変わる
2. 遷移後に main 内の h1 にフォーカスがある
3. 初回読み込み時にフォーカスを奪わない返ってくるテストは、次のような形になります(例です。リンク名とURLは自分のアプリに合わせます)。
// e2e/route-a11y.spec.ts
import { test, expect } from "@playwright/test";
test("遷移後にタイトルが変わり、h1にフォーカスが移る", async ({ page }) => {
await page.goto("/");
await page.getByRole("link", { name: "料金プラン" }).click();
await expect(page).toHaveURL(/\/pricing/);
await expect(page).toHaveTitle(/料金プラン/);
await expect(page.getByRole("heading", { level: 1 })).toBeFocused();
});
test("初回読み込みではフォーカスを奪わない", async ({ page }) => {
await page.goto("/");
const tag = await page.evaluate(() => document.activeElement?.tagName);
expect(tag).toBe("BODY");
});使っている toHaveTitle、toHaveURL、toBeFocused はいずれもPlaywrightの標準アサーションです。getByRole は、ユーザーや支援技術がページを捉える形に沿ったロケーターとして案内されており、アクセシビリティの検証と相性が合います。
npx playwright test e2e/route-a11y.spec.ts実装前は、タイトルかフォーカスのどちらかで落ちます。落ち方をそのままClaudeに読ませると、次の修正の的が絞れます。
手順2: React Routerで直す
React Routerの useLocation は、現在のロケーションを返します。ドキュメントでも、ロケーションが変わるたびに副作用を走らせる用途が例示されています。フォーカス移動は、この副作用として書けます。
// app/RouteFocus.tsx
import { useEffect, useRef } from "react";
import { useLocation } from "react-router";
export function RouteFocus() {
const { pathname } = useLocation();
const isFirstRender = useRef(true);
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false; // 初回表示では動かさない
return;
}
const heading = document.querySelector<HTMLElement>("main h1");
if (heading) {
heading.setAttribute("tabindex", "-1");
heading.focus();
}
}, [pathname]);
return null;
}ポイントは3つあります。
tabindex="-1"を付ける。見出しは本来フォーカスできない要素です。MDNによると、負の値はTabキーの巡回には入らず、スクリプトからフォーカスだけ受け取れる指定です- 初回表示では何もしない。読み込み直後にフォーカスを奪うと、利用者が自分でページの先頭から読む流れを邪魔します
pathnameだけを依存に入れる。クエリ文字列の変更でも見出しへ飛ばすかどうかは、画面の性質で決めます
タイトルは各ルートのコンポーネントで更新します。
// app/useDocumentTitle.ts
import { useEffect } from "react";
export function useDocumentTitle(title: string) {
useEffect(() => {
document.title = title;
}, [title]);
}RouteFocus はページの親ではなく、レイアウトの中でページと並ぶ兄弟要素です。Reactの副作用は兄弟の並び順に走るので、<RouteFocus /> はレイアウトのJSXで <Outlet /> より後ろに置きます。そうすればページ側のタイトル更新が先に済み、そのあとでフォーカスが動きます。アプリ全体を包むレイアウトに1つ置けば、全ページに効きます。
手順3: Next.js App Routerで直す
Next.jsではタイトルを metadata で持たせ、通知の入口はアナウンサーに任せます。足りないフォーカス移動だけをクライアントコンポーネントで補います。
usePathname はクライアントコンポーネント用のフックで、現在のパス名を返します。サーバーコンポーネントからは読めません。
// app/RouteFocus.tsx
"use client";
import { useEffect, useRef } from "react";
import { usePathname } from "next/navigation";
export function RouteFocus() {
const pathname = usePathname();
const isFirstRender = useRef(true);
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
const heading = document.querySelector<HTMLElement>("main h1");
heading?.setAttribute("tabindex", "-1");
heading?.focus();
}, [pathname]);
return null;
}これを app/layout.tsx の <body> 内に置きます。構成はNext.jsのApp Router移行の後でも変わりません。
ここで注意したいのは、フォーカスを動かすと、見出しの内容が読み上げられることです。Next.jsのアナウンサーも同じタイミングでタイトルを読むため、二重に聞こえる可能性があります。どちらを残すかは、スクリーンリーダーで聞いて決めるのが確実です。この点はPlaywrightでは判定できません。
手順4: 結果の通知にlive regionを置く
ルート遷移とは別に、画面内で結果が変わる場所があります。検索結果の件数、保存完了、絞り込みの反映などです。ここにlive regionを使います。
MDNの説明では、aria-live を持つ要素はあらかじめ空の状態で置き、更新が起きたときに短い告知文を入れます。スクリーンリーダーはページ読み込み時に内容をバッファするため、後から追加された内容は気づかれないことがあるからです。ステータス用途ならWCAGの解説が挙げる role="status" が適しています。
// 件数表示。要素は常に存在させ、中身だけ更新する
<p role="status">
{isLoading ? "" : `${results.length}件見つかりました`}
</p>aria-live の値は2種類です。polite は現在の読み上げが一段落してから通知し、assertive は即座に割り込みます。MDNは、割り込みが利用者を混乱させうるため、緊急でなければ assertive を使わないよう注意しています。
WCAGの解説には、もう1つ実用的な指摘があります。「0 items」が「3 items」に変わる例で、数字だけを更新対象にすると「3」とだけ読まれることがあるため、文字列全体を状態メッセージにするほうがよい、という内容です。件数なら、数字ではなく「18件見つかりました」の文全体を差し替えます。
テストは、status ロールの要素の文言を見ます。
test("検索後に件数がstatusに出る", async ({ page }) => {
await page.goto("/search");
await page.getByRole("searchbox").fill("claude");
await expect(page.getByRole("status")).toHaveText(/\d+件見つかりました/);
});手順5: Claude Codeに繰り返し検証させる設定
修正を1回で終わらせず、他のルートにも広げるには、検証の観点を固定しておくと安定します。2つの置き場があります。
CLAUDE.mdに規約を書く
## ルート遷移のアクセシビリティ
- 新しいルートには必ず一意の document.title を設定する
- 遷移後は main 内の h1 にフォーカスが移ること(初回表示は除く)
- 結果通知は空の role="status" 要素を常設し、文言だけ更新する
- 修正後は npx playwright test e2e/route-a11y.spec.ts を実行して結果を示す検証専用のサブエージェントを作る
サブエージェントは、.claude/agents/ に置くMarkdownファイルで定義します。name と description が必須で、tools で使える道具を絞れます。プロジェクトに置いたものはバージョン管理に含められ、チームで共有できます。
---
name: route-a11y-checker
description: ルート遷移後のtitle・フォーカス・live regionを点検する。ルーティング関連の差分があったら使う
tools: Read, Grep, Glob, Bash
---
あなたはルート遷移のアクセシビリティを点検する担当です。
1. 新規・変更されたルートごとに title が設定されているか調べる
2. main 内に h1 があり、遷移後にフォーカスされる経路があるか調べる
3. 件数や保存結果の表示に role="status" があるか調べる
4. e2e/route-a11y.spec.ts を実行し、失敗したテスト名と原因を報告する
コードは直さず、見つけた箇所と根拠を返すこと。tools に Write と Edit を入れていないので、このサブエージェントは読み取りと実行だけを行います。検査役と修正役を分けると、直す側の思い込みが検査に混ざりにくくなります。サブエージェントの作り方はサブエージェントの解説にまとめています。起動は、名前を呼ぶか @ で指名します。
Playwrightで検証できることと、できないこと
ここまでのテストが確かめているのは、DOMの状態です。タイトルの文字列、document.activeElement、live regionの文言を見ています。
確かめていないのは、実際の読み上げです。スクリーンリーダーが何を、どの順で、何回読んだかは、ブラウザーを自動操作しても取れません。次の表のように、役割を分けて考えます。
| 確認したいこと | Playwright | 実機のスクリーンリーダー |
|---|---|---|
| タイトルが変わったか | Playwright確認できる | 実機のスクリーンリーダー不要 |
| フォーカスが見出しにあるか | Playwright確認できる | 実機のスクリーンリーダー不要 |
| live regionに文言が入ったか | Playwright確認できる | 実機のスクリーンリーダー不要 |
| 二重に読まれないか | Playwright確認できない | 実機のスクリーンリーダー必要 |
| 読み上げの自然さ・順序 | Playwright確認できない | 実機のスクリーンリーダー必要 |
自動テストが通ることは、読み上げが正しいことの証明になりません。DOMの前提が崩れていないかを毎回チェックし、聞こえ方は節目ごとに人が聞く、という分担です。axeによる静的な違反検出は、PlaywrightとaxeでPRのたびに検査する構成が別にあります。ルート遷移の挙動は、axeだけでは見つかりません。
見落としやすいつまずき
落とし穴
データ取得中にh1がまだ無い
遷移直後は、<h1> がデータ取得後に描画される画面があります。副作用の時点で見つからず、フォーカスが動かないままになります。ローディング表示の段階から <h1> を出すか、h1 が現れるまで待つ処理を入れます。
- フォーカスでスクロールが動く。MDNによると、
focus()は既定で要素が見える位置までスクロールします。位置を変えたくない場合はfocus({ preventScroll: true })を渡せます。ただし遷移時は、新しい画面の先頭に出るほうが自然な場合が多いです - 戻る・進むでも発火する。ブラウザーの戻る操作でも
pathnameは変わるので、フォーカス移動が走ります。履歴を戻ったときの位置復元と衝突しないか、操作して確かめます - 画面内のタブ切り替えでは動かない。
/items/1から/items/2のようにパラメーターだけ変わる遷移はpathnameが変わるので動きますが、タブ切り替えのような画面内の変化は対象外です。こちらは状態メッセージの領分です - h1が複数ある、または無い。
main h1で拾えない画面は、フォーカス先が決まりません。h1の有無は、ルート一覧とあわせてClaudeに調べさせると見つかります
どこまで任せるかの判断は、先のキーボード操作を洗い出す手順と同じです。見出しの構造やフォーカス順を機械的に直すのはClaudeの得意分野で、画面の目的に照らした最終判断は人に残ります。
まとめ
ルート遷移の修正は、タイトル、フォーカス、通知の3点です。主題が変わる遷移は見出しへのフォーカス移動で伝え、画面内の結果の変化はlive regionで伝える、という切り分けに従えば、足す処理は多くありません。Next.jsは通知の入口を備えているので、タイトルの質とフォーカスの補完に集中できます。
最初にテストを落とし、直し、同じテストで確かめる順序にしておけば、Claudeが他のルートへ広げるときも結果を比べられます。二重読み上げのような聞こえ方の問題だけは、スクリーンリーダーで1度聞いて決めてください。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。