Webフロントエンドでモーダル(ダイアログ)を自作するとき、多くの開発者は次の3つの機能を実装して「完成」としてしまう。
- 背景の黒いオーバーレイをクリックしたら閉じる
- キーボードの
Escapeキーを押したら閉じる - モーダル表示中に背後のページがスクロールしないよう
document.body.style.overflow = 'hidden'にする
自作の新しいタブダッシュボード「ZenithTab」の共通モーダルコンポーネント(src/components/common/Modal.tsx)も、v1.10.2 までまさにこの状態だった。マウス操作で動かしている限り、何の不都合もないように思えた。
しかし、キーボードだけで操作しようとした瞬間、あるいはスクリーンリーダー(音声読み上げ)を通した瞬間、このモーダルはアクセシビリティの落とし穴を露呈させた(Issue #45)。
Tabキーを数回押しただけで、フォーカスがモーダルの境界をすり抜けて背後の見えないリンクへと飛んでいく。モーダルを閉じると、画面の先頭にフォーカスが吹き飛び、ユーザーは自分がどこにいたのか完全に見失う。
外部の重厚なUIライブラリ(Radix UIやHeadless UIなど)に頼ることなく、ReactとWeb標準APIだけでWAI-ARIAに沿い、フォーカストラップ(Focus Trap)を備えたモーダルを組み上げる実装手順を解説する。
放置してはいけない3大アクセシビリティ欠陥
自作モーダルで放置されがちな致命的欠陥は、以下の3点に集約される。
1. フォーカスリーク(Focus Leak)
モーダルが開いている最中に Tab キーを押し続けると、モーダル内の最後の要素を抜けた後、モーダルの裏側にあるメイン画面のボタンやリンクへとフォーカスが移動してしまう。視覚的には半透明の黒い膜で覆われているのに、裏側のボタンがフォーカスを受け取ってしまい、Enter を押すと裏の操作が実行されてしまう。
2. 初期フォーカスの不在
モーダルが開いた瞬間、キーボードのフォーカスがどこにも当たっていない(または body に置かれたまま)。ユーザーはモーダル内の入力欄にたどり着くために、何度も無駄に Tab キーを連打しなければならない。
3. トリガー要素へのフォーカス復元(Restore Focus)
「設定」ボタンを押してモーダルを開き、編集を終えて閉じたとする。このとき、フォーカスは「設定」ボタンに戻るべきだ。しかし何の手当てもしていないと、モーダルがDOMから消滅した瞬間にブラウザはフォーカスを <body> へとリセットしてしまう。
ステップ1:フォーカストラップ(Focus Trap)の自作
モーダル内のフォーカス可能要素(ボタン、入力欄、リンクなど)を特定し、その範囲内だけで Tab と Shift + Tab を循環させるのがフォーカストラップだ。
// フォーカス可能なすべての要素を拾い出すセレクター
const FOCUSABLE =
'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
// hidden や aria-hidden の要素は除外する。
// offsetParent による「表示されているか」判定はあえてしない: jsdom では常に null になり、
// 開いているモーダルの中身はどうせ全部描画されている。
function focusableIn(root: HTMLElement): HTMLElement[] {
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
(el) => !el.hidden && el.getAttribute('aria-hidden') !== 'true'
);
}
このセレクターを用いて、キーダウンイベントを監視するエフェクトを構築する。
const cardRef = useRef<HTMLDivElement>(null);
// 呼び出し側は onClose にインラインの arrow 関数を渡してくる。
// それを ref に逃がしておくことで、下のエフェクトは「開いたとき」に1回だけ走る。
// 親が再描画されるたびに走らせると、入力中にフォーカスが先頭へ引き戻されてしまう。
const onCloseRef = useRef(onClose);
useEffect(() => {
onCloseRef.current = onClose;
}, [onClose]);
useEffect(() => {
if (!isOpen) return;
const card = cardRef.current;
// 1. モーダルを開く直前にフォーカスしていた要素を記憶
const previouslyFocused = document.activeElement as HTMLElement | null;
// 2. 初期フォーカス: 本文の最初のコントロール、無ければ閉じるボタン
const body = card?.querySelector<HTMLElement>('[data-modal-body]');
const first = (body && focusableIn(body)[0]) || card?.querySelector<HTMLElement>('[data-modal-close]');
first?.focus();
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onCloseRef.current();
return;
}
// 3. Tab / Shift+Tab をカードの中に閉じ込める
if (e.key === 'Tab' && card) {
const items = focusableIn(card);
if (items.length === 0) {
e.preventDefault();
return;
}
const firstItem = items[0];
const lastItem = items[items.length - 1];
const active = document.activeElement;
// フォーカスがすでにカードの外へ逃げていた場合も、次の Tab で連れ戻す
if (e.shiftKey && (active === firstItem || !card.contains(active))) {
e.preventDefault();
lastItem.focus();
} else if (!e.shiftKey && (active === lastItem || !card.contains(active))) {
e.preventDefault();
firstItem.focus();
}
}
};
document.addEventListener('keydown', handleKeyDown);
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = '';
// 4. 閉じた瞬間に、元のトリガー要素へフォーカスを復元(まだ DOM にあれば)
if (previouslyFocused && document.contains(previouslyFocused)) previouslyFocused.focus();
};
}, [isOpen]);
実装して初めて分かった3つの細部
教科書的なフォーカストラップとの差分が、実際に動かして分かった落とし穴だ。
(1) フォーカス可能要素は毎回数え直す。 最初に querySelectorAll した結果を閉じ込めておくと、モーダル内で「フィードを追加」してボタンが増えたときに末尾の要素がずれる。キーダウンのたびに focusableIn(card) を呼び直すのが正しい。
(2) onClose は ref に逃がす。 呼び出し側は onClose={() => closeSettingsModal()} のようにインライン関数を渡してくるので、依存配列に onClose を入れるとエフェクトが親の再描画のたびに走り直し、初期フォーカスの処理が 入力中のユーザーからフォーカスを奪う。依存を [isOpen] だけにし、最新の onClose は ref から読む。
(3) フォーカス復元は「まだ DOM にあれば」。 開く前にフォーカスしていた要素が、モーダルの操作の結果として消えていることがある(ウィジェットを削除した、など)。存在しない要素に focus() を呼んでも害はないが、document.contains() で確認しておくと意図が明確になる。
ステップ2:スクリーンリーダーに伝える WAI-ARIA 属性
目が見えるユーザーには「画面中央にポップアップが出た」ことが一目でわかるが、スクリーンリーダーを使う視覚障害者には、適切なARIA属性がなければ単なるHTMLの末尾にテキストが追加されたようにしか伝わらない。
モーダルのルート要素に以下の属性を付与する。
export const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children, maxWidth = 'lg' }) => {
const { t } = useTranslation();
const titleId = useId(); // 同じページに複数のモーダルがあっても衝突しない ID
// ...(上のエフェクト)
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center ...">
{/* 背景。クリックで閉じる */}
<div data-testid="modal-backdrop" className="fixed inset-0 bg-black/60 backdrop-blur-md" onClick={onClose} />
<div
ref={cardRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className="relative w-full bg-slate-900/85 ... rounded-2xl"
>
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10">
{/* aria-labelledby と一致するIDをタイトルに振る */}
<h2 id={titleId} className="text-lg font-semibold text-white">{title}</h2>
<button
type="button"
onClick={onClose}
aria-label={t.common.close}
title={t.common.close}
data-modal-close
>
<X size={20} />
</button>
</div>
<div data-modal-body className="px-6 py-5 overflow-y-auto flex-1">{children}</div>
</div>
</div>
);
};
各属性の持つ意味は極めて重大だ。
-
role="dialog": この要素が通常のコンテンツではなく、ユーザーの介入を求めるダイアログであることを支援技術に宣言する。 -
aria-modal="true": このダイアログが表示されている間、ダイアログの外側にあるコンテンツが非活性であることを支援技術に通知する(多くのスクリーンリーダーが、外側を読み上げ対象から外す)。 -
aria-labelledby={titleId}: ダイアログが開いた瞬間に、スクリーンリーダーが<h2>のタイトル文字列(例:「ウィジェットの追加」)を自動的に読み上げる。ID は React 18 のuseId()で生成し、ページ内で一意にする。 -
aria-label={t.common.close}: アイコンだけの「×」ボタンに、音声で何を意図したボタンかを明確に伝える。文言はハードコードせず、7言語のロケールファイルから引く。
data-modal-body / data-modal-close は見た目に影響しないマーカー属性で、初期フォーカスの行き先をクラス名に依存せず特定するために使っている。
テストで固定する
この種の挙動は、実装した本人以外には「なぜこのコードがあるのか」が分かりにくい。次の改修で onCloseRef を「無駄に複雑だ」と消されないように、Testing Library で振る舞いをテストに固定した(tests/components/Modal.test.tsx)。
-
role="dialog"とaria-modal、aria-labelledbyがタイトルに結び付いていること - 開いたとき、本文の最初のコントロールにフォーカスが当たること
- 末尾で
Tab、先頭でShift+Tabを押すと反対側へ回ること -
EscapeでonCloseが呼ばれること - 閉じたあと、開く前にフォーカスしていたボタンにフォーカスが戻ること
E2E(Playwright)側でも「設定パネルを開き、タブを切り替え、Escape で閉じる」シナリオが同じコンポーネントを通っている。
ステップ3(未着手):背景要素の inert 化
さらに一歩進めるなら、HTML標準属性の inert を併用するのが理想的だ。
モーダルが開いている間、背後のメインコンテンツ領域(ZenithTab なら #zenith-root 配下のダッシュボード)に inert 属性を付与することで、ブラウザ自身に「背後の要素はクリックもフォーカスもテキスト選択も一切受け付けない」状態を作らせることができる。
// 将来の拡張案。現在の ZenithTab には入っていない
useEffect(() => {
const main = document.getElementById('main-content');
if (!main) return;
if (isOpen) main.setAttribute('inert', '');
else main.removeAttribute('inert');
return () => main.removeAttribute('inert');
}, [isOpen]);
ZenithTab で現時点でこれを見送っているのは、モーダルがダッシュボードと同じ React ツリーの中(同じルート要素の子)に描画されているためだ。inert はモーダルを ポータルで別のルートに出す構成と組み合わせて初めて意味を持つ。今のフォーカストラップは「フォーカスが外に逃げても次の Tab で連れ戻す」実装なのでキーボード操作は守れているが、inert を併用すれば JavaScript の監視に頼らずブラウザに閉じ込めを任せられる。ポータル化と合わせて次の改修候補にしている。
「誰でも使える」を当たり前にする
アクセシビリティ(a11y)は、余裕があるときに付け足す「おまけ」ではない。
マウスを持たない環境、トラックパッドが使えない状況、画面を目視できないユーザー――そうした状況でも道具として破綻なく機能することこそが、優れたUIコンポーネントの証だ。
外部ライブラリを入れずとも、100行足らずのコードでフォーカストラップとWAI-ARIA対応は実現できる。ZenithTab の場合、設定パネル・ウィジェット追加・ウィジェット設定・アプリドロワーの4つのダイアログがこの1つの Modal を共有しているので、直す場所も1か所で済んだ。
自作モーダルを作るときは、ぜひ Tab キーだけで開いて閉じるテストを行ってみてほしい。それだけで、プロダクトの品質は一段上のステージへと引き上げられる。
さいごに:ZenithTabについて
本記事で紹介した設計やトラブルシューティングの知見は、すべて自作の新しいタブChrome拡張機能「ZenithTab(ゼニスタブ)」の開発を通して得られたものです。
ZenithTabは、「ブラウザを開くたびに心地よく、作業に集中できる」をコンセプトにした、完全ローカル完結・プライバシー重視のダッシュボード拡張機能です。
- 自由なグリッド配置: 時計、天気、カレンダー、RSSリーダー、集中タイマー、習慣トラッカー、メモなど、多彩なウィジェットをグリッド上で自由に配置
- 安心のローカル完結: 外部サーバーへのデータ送信は一切行わず、すべての設定やメモはブラウザ内に安全に保存
- 細部へのこだわり: ガラスモーフィズム(すりガラス調UI)、ダイナミック壁紙、軽快なキーボードショートカット、そして万が一の誤操作を防ぐ「元に戻す(Ctrl+Z)」や自己修復機能を完備
Chromeウェブストアで無料公開しています。日々の作業効率化や、技術的なUI/UXの触感のお試しとして、ぜひ気軽に使ってみてください!
- 🌐 Chrome ウェブストアでインストール:
ZenithTab - Chrome ウェブストア - 🐙 GitHub リポジトリ(完全オープンソース):
miyabiver39/ZenithTab
ソースコードはGitHubで公開しています。「面白い」「役に立った」と思っていただけたら、GitHubのスター(⭐️) や記事への いいね / ストック をいただけると、開発の大きな励みになります!
