Claude Media
Claude CodeでSPAの画面遷移後のフォーカスと読み上げを直す

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度聞いて決めてください。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/sub-agents
  2. w3.org/WAI/WCAG22/Understanding/status-messages.html
  3. nextjs.org/docs/architecture/accessibility
  4. developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/ari…
  5. playwright.dev/docs/api/class-pageassertions
この記事を共有:XはてブLinkedIn