Reactモーダルのアクセシビリティ:フォーカストラップとdialog要素の落とし穴
Reactモーダルのフォーカストラップ、ESCで閉じる、背景スクロール固定、aria-modal、フォーカス復帰、dialog要素と自作の違いを実装コード付きで。
「いい感じのモーダル作っといて」とClaude Codeに頼んだら、5秒で見た目の整った削除確認ダイアログが出てきました。半透明の背景、角丸、影。完璧に見えた。
でも僕がマウスを机の端に追いやって、Tabキーだけで操作してみたら、3回押した瞬間にフォーカスがモーダルの裏側へすり抜けて、見えないはずの「ログアウト」ボタンに当たっていました。Enterを押したら、確認ダイアログを開いたままログアウト。
これ、キーボードユーザーやスクリーンリーダー利用者にとっては「事故」です。しかも見た目では一切わからない。今日はモーダルの一番の難所、アクセシビリティだけに絞って、何が自動で、何を自分で書かなきゃいけないのかを整理します。
この記事の要点
- モーダルの本質は見た目じゃなく、開いている間の操作の閉じ込め方。フォーカストラップ・ESC・背景固定・フォーカス復帰の4点が揃って初めて「使えるモーダル」になる。
- ブラウザ標準の
<dialog>をshowModal()で開くと、ESCで閉じる・背景をinert化・背景スクロール固定は無料でついてくる。でもTabのフォーカストラップだけは自動でやってくれない(ここが最大の誤解)。 - だから自作でも
<dialog>利用でも、Tab/Shift+Tabを中で循環させるコードは自分で書く。本記事にコピペで動くReactフックを置いた。 - スクリーンリーダー向けには
aria-modal="true"とaria-labelledby(タイトル参照)が要る。role="dialog"は<dialog>要素なら暗黙で付く。 - 複雑なUIや多段モーダルが必要なら、自作にこだわらず Radix UIのDialog を土台にしたほうが事故が減る。
モーダルで「閉じ込める」べき4つのもの
モーダルを開くというのは、ユーザーの注意を一点に集める行為です。だから開いている間は、裏側の世界を一時的に消す必要があります。消すべき対象は4つ。
| 閉じ込める対象 | 何が起きると事故か | 担当する仕組み |
|---|---|---|
| キーボードのフォーカス | Tabで裏のボタンに当たる | フォーカストラップ |
| 閉じる操作 | ESCで閉じられず手詰まり | ESCキーハンドリング |
| 背景のスクロール | 裏のページが一緒に動く | スクロール固定 / inert |
| 閉じた後の現在地 | フォーカスが先頭に飛ぶ | 呼び出し元へ復帰 |
「目立つ箱」を作るのは誰でもできます。難しいのはこの4つを全部閉じることで、Claude Codeに「アクセシブルにして」とだけ投げると、たいてい1つか2つ抜け落ちます。なぜ抜けるのか。次の節で、ブラウザが何を肩代わりしてくれるのかを見るとわかります。
<dialog> 要素が無料でくれるもの、くれないもの
まず大前提。今のブラウザにはHTML標準の <dialog> 要素があります。これを dialog.showModal() で開くと、けっこうな量の面倒を肩代わりしてくれます。MDNの <dialog> 要素のリファレンス に挙動が明記されています。
ここで多くの人が勘違いするのが、「<dialog> を使えばアクセシビリティは全部おまかせ」という思い込みです。実際はこうです。
showModal() が自動でやってくれること:
Escapeキーで閉じる(モーダルとして開いた場合のみ)- 背景を
inert(操作不能)にして、裏のボタンをクリックさせない - 背景のスクロールを止める
- 開いた瞬間、中の最初のフォーカス可能な要素へフォーカスを移す
::backdrop擬似要素で背景を装飾できる- 暗黙で
role="dialog"が付く
showModal() が自動でやってくれないこと:
Tab/Shift+Tabのフォーカストラップ(最後の要素から先頭へ、先頭から最後へ循環させること)- 閉じた後、開いたボタンへフォーカスを戻すこと(
<dialog>は割と戻してくれるが、SPAでは要素が消えて外れることがある) - 外側クリックで閉じる「ライトディスミス」(後述の
closedby属性が要る)
つまり一番の難所であるフォーカストラップだけは、<dialog> を使っても自分で書くしかありません。逆に言えば、ESCと背景固定をわざわざ自前のJavaScriptで実装している自作モーダルは、<dialog> に乗り換えるだけでコードがごっそり減ります。
<!-- これだけで ESC・背景inert・背景スクロール固定が効く -->
<dialog id="confirm">
<h2>本当に削除しますか?</h2>
<button autofocus>キャンセル</button>
<button class="danger">削除する</button>
</dialog>
<script>
// open 属性を直接書かず、showModal() で開くのが鉄則。
// show() や open 属性だけだと「モーダル」にならず背景が触れてしまう。
document.querySelector("#confirm").showModal();
</script>
コピペで動くフォーカストラップ(React)
ここが本記事の中心です。<dialog> がくれない Tab の循環を、React側で実装します。考え方はシンプルで、Tabが押されたとき「今フォーカスが最後の要素にいたら先頭へ」「Shift+Tabで先頭にいたら最後へ」と、手で巻き戻すだけです。
下の useFocusTrap は、モーダルのルート要素の ref を渡すと、開いている間だけ Tabを中に閉じ込め、閉じたら開く前にフォーカスしていた要素へ自動で戻すフックです。Viteでも、Next.jsのClient Component(先頭に "use client";)でもそのまま動きます。
import * as React from "react";
// フォーカスを当てられる要素を拾うセレクタ。
// tabindex="-1"(プログラム用)は除外する。
const FOCUSABLE = [
"a[href]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
"[tabindex]:not([tabindex='-1'])",
].join(",");
export function useFocusTrap(
ref: React.RefObject<HTMLElement | null>,
active: boolean,
) {
React.useEffect(() => {
const node = ref.current;
if (!active || !node) return;
// 1. 開く直前のフォーカス位置を覚えておく(戻り先)
const opener =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
// 2. 開いたら中の最初の要素へフォーカスを移す
const focusables = () =>
Array.from(node.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
(el) => el.offsetParent !== null, // display:none は除外
);
(focusables()[0] ?? node).focus();
// 3. Tab を監視して、端まで来たら反対側へ巻き戻す
function onKeyDown(e: KeyboardEvent) {
if (e.key !== "Tab") return;
const items = focusables();
if (items.length === 0) return;
const first = items[0];
const last = items[items.length - 1];
const current = document.activeElement;
if (e.shiftKey && current === first) {
e.preventDefault();
last.focus(); // 先頭で Shift+Tab → 末尾へ
} else if (!e.shiftKey && current === last) {
e.preventDefault();
first.focus(); // 末尾で Tab → 先頭へ
}
}
node.addEventListener("keydown", onKeyDown);
// 4. 閉じたら、覚えておいた呼び出し元へフォーカスを返す
return () => {
node.removeEventListener("keydown", onKeyDown);
opener?.focus();
};
}, [ref, active]);
}
これを <dialog> と組み合わせると、アクセシビリティの4点(トラップ・ESC・背景固定・復帰)が全部そろったモーダルになります。aria-modal・aria-labelledby を忘れず付けるのがポイントです。
import * as React from "react";
import { useFocusTrap } from "./useFocusTrap";
type Props = {
open: boolean;
title: string;
onClose: () => void;
children: React.ReactNode;
};
export function Modal({ open, title, onClose, children }: Props) {
const dialogRef = React.useRef<HTMLDialogElement>(null);
const titleId = React.useId();
// フォーカストラップ&復帰を有効化
useFocusTrap(dialogRef, open);
// open の変化に合わせて showModal/close を呼ぶ
React.useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open && !dialog.open) dialog.showModal();
if (!open && dialog.open) dialog.close();
}, [open]);
// dialog の close(ESC含む)を React state に同期
React.useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
const handleClose = () => onClose();
// 背景クリックで閉じる(任意)
const handleClick = (e: MouseEvent) => {
if (e.target === dialog) onClose();
};
dialog.addEventListener("close", handleClose);
dialog.addEventListener("click", handleClick);
return () => {
dialog.removeEventListener("close", handleClose);
dialog.removeEventListener("click", handleClick);
};
}, [onClose]);
return (
<dialog ref={dialogRef} aria-modal="true" aria-labelledby={titleId}>
<h2 id={titleId}>{title}</h2>
{children}
</dialog>
);
}
aria-modal="true" は「このダイアログの外は読まなくていい」とスクリーンリーダーに伝える宣言です。aria-labelledby でタイトルの id を指すと、開いた瞬間に「本当に削除しますか? ダイアログ」と読み上げてくれます。この2つが抜けると、見た目は完璧でも音声では「ダイアログ」とだけ読まれて、何のための画面か伝わりません。
ESCで閉じる・背景固定・外側クリックの細かい話
ここで一段細かい挙動を詰めておきます。Claude Codeのレビューでよく差し戻すポイントです。
ESCで閉じる: showModal() で開いたモーダルは、ESCで閉じるのが標準です。自作で position: fixed の <div> を重ねている場合は、keydown で Escape を拾って自分で閉じる必要があります。WAI-ARIAの Dialog (Modal) Pattern でも、ESCで閉じることは必須の操作として挙げられています。
背景スクロール固定: <dialog> ならブラウザが止めてくれます。自作の場合は開いている間だけ document.body.style.overflow = "hidden" を当て、閉じたら戻すのが定番です。iOSのSafariはこれだけだと裏が動くことがあるので、本文側に overflow: auto と overscroll-behavior: contain を併用すると安定します。
外側クリックで閉じる(ライトディスミス): 新しいブラウザでは <dialog closedby="any"> を付けると、背景クリックとESCの両方で閉じられます。値は次の3つです。
closedby の値 | 閉じ方 |
|---|---|
any | 外側クリック + ESC + 自前のボタン |
closerequest | ESC + 自前のボタン(showModal() の既定) |
none | 自前のボタンのみ(誤操作で消えない) |
削除のように取り消しにくい操作では、外側クリックで即消える any はむしろ危険です。none や closerequest にして、明示的にボタンを押させるほうが安全なこともあります。closedby がまだ効かない環境向けには、上のReact例のように click イベントで e.target === dialog を見て手当てしておくと両対応できます。
Claude Codeに渡す依頼文(a11yを完了条件にする)
「アクセシブルにして」では弱い、と書きました。代わりに、検証項目そのものを完了条件として渡します。これをコピペして対象ファイル名だけ変えると、レビューしやすい差分になります。
React + TypeScript の既存画面にモーダルを追加してください。
見た目より先に、次の動作を満たすことを完了条件とします。
必須(アクセシビリティ):
- HTML の dialog 要素を優先し、showModal() で開く
- Tab / Shift+Tab がモーダル内で循環する(端で反対側へ折り返す)
- Escape で閉じる
- 閉じたら、開いたボタンへフォーカスを戻す
- aria-modal="true" と aria-labelledby(タイトル参照)を付ける
- outline は消さず、:focus-visible で見える状態にする
- 320px 幅でも本文と確定ボタンが切れない
依頼の前提:
- まず既存のボタン・CSS・テストを読んでから提案する
- dialog を使わない場合は、なぜ使えないかを説明する
- 失敗例と手動確認手順(キーボードだけの操作)を PR 本文に残す
触ってよいファイル:
- src/components/Modal.tsx
- src/components/modal.css
- tests/modal.spec.ts
前提として既存コードを読ませる一文を入れておくと、Claude Codeが手元の命名規約やCSSに寄せた提案を返してくれます。CLAUDE.md にこの完了条件を常設しておくのも手です。a11y全般の段取りは Claude Codeでアクセシビリティ対応を実装するワークフロー にまとめてあるので、依頼の型を増やしたいときに使ってください。
僕がやらかしたモーダルの失敗3つ
正直に書きます。最初に作ったモーダルは、a11yの観点で穴だらけでした。
ひとつ目は、outline: none を全要素に当てたこと。デザイナーから「フォーカスの青枠が野暮ったい」と言われ、CSSのリセットで全部消しました。マウスでは快適。でもキーボードに切り替えた瞬間、今どこにいるのか完全に見えなくなりました。今は消さずに :focus-visible でデザインに合うリングを出しています。
ふたつ目は、閉じた後のフォーカスを放置したこと。モーダルを閉じると、フォーカスがページの先頭(<body>)に飛んでいました。スクリーンリーダーだと、削除確認を閉じた後にページのトップから読み直しになる。開く前の位置を document.activeElement で覚えて戻すようにしたら、ようやく自然になりました。
みっつ目は、タイトルを見た目の都合で消したこと。デザイン上ヘッダーを省いたモーダルで、aria-labelledby も付け忘れたまま公開。NVDAで開くと「ダイアログ」としか読まれませんでした。見た目に出さない場合でも aria-label で名前は必ず要る、と痛感しました。
キーボードとスクリーンリーダーで確認する
自動テストだけでは読み上げ品質まではわかりません。それでも、開閉とフォーカスの事故はかなり拾えます。Playwrightで最低限を固めます。
import { expect, test } from "@playwright/test";
test("モーダルが開閉し、フォーカスが戻る", async ({ page }) => {
await page.goto("/settings");
const trigger = page.getByRole("button", { name: "プロジェクトを削除" });
await trigger.click();
// role=dialog で取れる = aria が効いている証拠
const dialog = page.getByRole("dialog", { name: "本当に削除しますか?" });
await expect(dialog).toBeVisible();
// Tab してもフォーカスが見える(裏に抜けていない)
await page.keyboard.press("Tab");
await expect(page.locator(":focus")).toBeVisible();
// ESC で閉じ、開いたボタンへ戻る
await page.keyboard.press("Escape");
await expect(dialog).toBeHidden();
await expect(trigger).toBeFocused();
});
そのうえで、最後は必ず手で触ります。手順はこれだけです。
- マウスを使わず、
TabShift+TabEnterSpaceEscapeだけで開閉できるか。 Tabを押し続けて、フォーカスがモーダルの外へ漏れないか(端で折り返すか)。- WindowsならNVDA、macOSならVoiceOverで、開いた瞬間にタイトルとボタン名が読まれるか。
- ブラウザの開発者ツールで幅を320pxにして、確定ボタンまで指が届くか。モバイル幅の詰め方は Claude Codeでレスポンシブデザインを実装する が参考になります。
よくある質問
Q. <dialog> 要素と自作の div モーダル、どっちを使うべき?
基本は <dialog> です。ESC・背景inert・背景スクロール固定をブラウザが肩代わりしてくれるので、コードが減って事故も減ります。div 自作にするのは、アニメーションを細かく制御したい、古い環境を厚くサポートする、といった明確な理由があるときだけにします。
Q. <dialog> を使えばフォーカストラップも自動?
いいえ。これが一番の誤解です。showModal() は開いた瞬間のフォーカス移動と背景inert化はしますが、Tabをモーダル内で循環させる処理はしてくれません。本記事の useFocusTrap のように自分で書く必要があります。
Q. aria-modal="true" と背景の inert、両方要る?
役割が違います。aria-modal はスクリーンリーダーに「外は読むな」と伝える属性、inert は実際にキーボードとマウスの操作対象から外す属性です。<dialog> の showModal() は inert 相当を自動で当てますが、div 自作なら背景に inert 属性を付け、aria-modal も併記するのが安全です。
Q. モーダルの中からさらにモーダルを開いてもいい? おすすめしません。多段モーダルはフォーカスの戻り先、ESCがどれを閉じるか、確認の責任が一気に複雑になります。削除確認の上にさらに確認を重ねるより、文言を明確にして、取り消し導線(Undo)を用意するほうが親切です。どうしても階層UIが必要なら Radix UIのDialog のように実績ある部品に任せます。
Q. トースト通知も同じ aria で作れる?
いいえ。モーダルは注意を奪う role="dialog"、トーストは邪魔せず伝える aria-live を使います。性質が逆なので実装も別です。詳しくは アクセシブルなトースト通知の実装 にまとめてあります。
実際に試した結果
冒頭の「Tabで裏のログアウトに当たる」モーダル以来、僕はモーダルのレビューで見た目を最後に回すようになりました。最初に見るのは、マウスを脇に置いて Tabだけで一周できるか、ESCで閉じて元のボタンに戻るか、NVDAでタイトルが読まれるか。この3点です。
検証用のReact画面で比べてみると、「アクセシブルにして」と漠然と頼んだときはフォーカストラップが毎回抜けていました。一方、本記事の完了条件(トラップ・ESC・復帰・aria-modal・320px幅)をそのまま渡すと、Claude Codeの初回出力でほぼ全部そろい、手戻りが目に見えて減りました。<dialog> に寄せたことで、自前で書いていたESCと背景固定のコードも消えました。
結論はシンプルです。モーダルのアクセシビリティは、賢いプロンプトより何が自動で何が手作業かを正しく知ることで決まります。<dialog> に任せられる3つは任せ、フォーカストラップだけは自分で握る。これが一番速くて安全でした。型を手元に置きたい人は 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にビルド→スモークテスト→自動修正を回させる足場の作り方
最小スモークテストの選び方、失敗ログを食わせて直させるループ、回数上限と確認ゲートで暴走を止める方法を、コピペで動くコード付きで紹介します。