Web Components で作るモーダルダイアログ:フォーカス管理・閉じる動線まで実装

モーダルダイアログは、ユーザーの操作をいったん止めて重要な情報を表示したり、確認を求めたりするための UI パターンとして広く使われています。しかし、ただ見た目を作るだけでは不十分で、キーボード操作への対応やフォーカス管理、適切な閉じる動線の実装など、アクセシビリティとユーザビリティの観点で押さえるべきポイントがいくつもあるんです。
本記事では、Web Components を使ってモーダルダイアログを一から実装する方法を、フォーカストラップ(フォーカスの閉じ込め)や Esc キーでの閉じる動線、オーバーレイクリックでの閉じる処理まで、実践的なコード例とともに解説いたします。
背景
モーダルダイアログとは
モーダルダイアログは、ページの他の操作を一時的にブロックして、ユーザーに特定のアクションを促す UI コンポーネントです。ログイン画面、確認ダイアログ、画像のプレビュー表示など、さまざまな場面で利用されていますね。
Web Components を選ぶ理由
Web Components は、HTML・CSS・JavaScript を組み合わせて再利用可能なカスタム要素を作る仕組みです。フレームワークに依存せず、ネイティブの Web API だけで実装できるため、どんなプロジェクトでも使いまわせる汎用性の高いコンポーネントを作れます。
モーダルダイアログのように、複数のページやプロジェクトで頻繁に使う UI パターンは、Web Components で実装することで大きなメリットが得られるでしょう。
以下の図は、Web Components によるモーダルダイアログの基本構造を示しています。
mermaidflowchart TB
shadow["Shadow DOM<br/>(内部スタイル・構造)"]
template["<template><br/>(再利用可能な HTML)"]
custom["<modal-dialog><br/>(カスタム要素)"]
template -->|挿入| shadow
shadow -->|包含| custom
custom -->|公開| page["ページ HTML"]
図の要点:テンプレートで定義した HTML が Shadow DOM 内に挿入され、カスタム要素として外部に公開されます。
アクセシビリティの重要性
モーダルを開いたとき、キーボード操作だけでダイアログ内のボタンやフォームにアクセスできるか、スクリーンリーダーが適切に読み上げるかなど、アクセシビリティへの配慮が欠かせません。特にフォーカス管理は、視覚障害のあるユーザーや、マウスを使わないユーザーにとって非常に重要です。
課題
フォーカスが外に逃げてしまう問題
モーダルを表示したとき、Tab キーでフォーカスを移動すると、ダイアログの外(背景のページ)にフォーカスが移動してしまうことがあります。これでは、ユーザーがモーダル内の操作に集中できず、操作ミスやアクセシビリティの問題につながるでしょう。
閉じる動線が不明確
ユーザーがモーダルを閉じる方法が分かりにくいと、ストレスを感じてしまいますよね。一般的には以下の 3 つの方法でモーダルを閉じられるようにすることが推奨されます。
- 閉じるボタン(× ボタンなど)のクリック
- Esc キーの押下
- オーバーレイ(背景の暗い部分)のクリック
スタイルの衝突
モーダルのスタイルがページ全体の CSS と干渉して、意図しない見た目になってしまう問題もあります。Web Components の Shadow DOM を使えば、スタイルをカプセル化して外部との衝突を防げますが、適切な設計が必要です。
以下の図は、モーダルダイアログで発生しがちな課題を整理したものです。
mermaidflowchart LR
issue1["フォーカスが<br/>外に逃げる"]
issue2["閉じる動線が<br/>不明確"]
issue3["スタイル衝突"]
issue1 -->|解決策| trap["フォーカストラップ"]
issue2 -->|解決策| close["Esc・オーバーレイ<br/>クリック対応"]
issue3 -->|解決策| shadow2["Shadow DOM<br/>スタイル分離"]
図の要点:各課題に対して、フォーカストラップ、複数の閉じる動線、Shadow DOM によるスタイル分離が有効です。
解決策
フォーカストラップの実装
モーダルを開いたら、フォーカスをダイアログ内のフォーカス可能な要素(ボタン、入力欄など)に限定します。Tab キーや Shift+Tab キーでフォーカスを移動しても、ダイアログ内をループするようにすることで、ユーザーの操作がダイアログの外に出ないようにするのです。
複数の閉じる動線を用意
閉じるボタン、Esc キー、オーバーレイクリックの 3 つの動線を実装します。これにより、マウス操作派の方もキーボード操作派の方も、直感的にモーダルを閉じられますね。
Shadow DOM によるスタイルのカプセル化
Shadow DOM を使えば、モーダルのスタイルを完全に独立させることができます。外部の CSS がモーダル内に影響せず、逆にモーダルのスタイルが外部に漏れることもありません。
ARIA 属性でアクセシビリティを強化
role="dialog"
、aria-modal="true"
、aria-labelledby
などの ARIA 属性を適切に設定することで、スクリーンリーダーがモーダルを正しく認識して読み上げられるようにします。
以下の図は、解決策の全体像を示したものです。
mermaidflowchart TB
open["モーダルを開く"] --> focus["フォーカスを<br/>ダイアログ内へ"]
focus --> trap2["フォーカストラップ<br/>有効化"]
trap2 --> wait["ユーザー操作待ち"]
wait -->|閉じるボタン| closeAction["モーダルを閉じる"]
wait -->|Esc キー| closeAction
wait -->|オーバーレイクリック| closeAction
closeAction --> restore["元の要素へ<br/>フォーカス復帰"]
図の要点:モーダルを開いたらフォーカスを移動し、複数の閉じる動線を経て元の要素にフォーカスを戻します。
具体例
プロジェクトの準備
まずは、プロジェクトディレクトリを作成し、必要なファイルを用意しましょう。
bashmkdir web-components-modal
cd web-components-modal
touch index.html modal-dialog.js
HTML ファイルの作成
index.html
では、作成したカスタム要素 <modal-dialog>
を読み込み、開くボタンを配置します。
html<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
/>
<title>Web Components モーダルダイアログ</title>
</head>
<body>
<h1>Web Components モーダルサンプル</h1>
<button id="openModalBtn">モーダルを開く</button>
<!-- カスタム要素のモーダルダイアログ -->
<modal-dialog id="myModal">
<h2 slot="title">確認ダイアログ</h2>
<p slot="content">
この操作を実行してもよろしいですか?
</p>
</modal-dialog>
<script type="module" src="./modal-dialog.js"></script>
<script>
// モーダルを開くボタンのイベント
document
.getElementById('openModalBtn')
.addEventListener('click', () => {
document.getElementById('myModal').open();
});
</script>
</body>
</html>
この HTML では、<modal-dialog>
タグの中に slot
属性を使ってタイトルと本文を挿入しています。slot
を使うことで、モーダルの内容を外部から柔軟に指定できるんですね。
カスタム要素の定義
modal-dialog.js
で Web Components としてモーダルダイアログを定義します。まず、テンプレートとスタイルを作成しましょう。
javascript// テンプレート HTML を定義
const template = document.createElement('template');
template.innerHTML = `
<style>
/* オーバーレイ(背景の暗い部分) */
.overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 1000;
justify-content: center;
align-items: center;
}
.overlay.show {
display: flex;
}
ここでは、オーバーレイのスタイルを定義しています。display: none
で非表示にし、show
クラスが追加されたときに display: flex
で中央揃えになるようにしました。
次に、ダイアログ本体のスタイルを追加します。
javascript /* ダイアログ本体 */
.dialog {
background-color: white;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
max-width: 500px;
width: 90%;
padding: 24px;
position: relative;
}
/* 閉じるボタン */
.close-btn {
position: absolute;
top: 12px;
right: 12px;
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #666;
}
.close-btn:hover {
color: #000;
}
</style>
ダイアログは白い背景に角丸、影をつけて立体感を出しています。閉じるボタンは右上に配置し、ホバー時に色が変わるようにしました。
続いて、テンプレートの HTML 部分を定義します。
javascript <div class="overlay" part="overlay">
<div class="dialog" role="dialog" aria-modal="true" aria-labelledby="dialog-title" part="dialog">
<button class="close-btn" aria-label="閉じる">×</button>
<div id="dialog-title">
<slot name="title">タイトル</slot>
</div>
<div>
<slot name="content">本文</slot>
</div>
<div>
<slot name="actions"></slot>
</div>
</div>
</div>
`;
role="dialog"
と aria-modal="true"
を設定して、スクリーンリーダーにモーダルであることを伝えます。slot
を使って、外部から title、content、actions を挿入できるようにしました。
クラスの定義とライフサイクル
次に、カスタム要素のクラスを定義します。まずはコンストラクタと Shadow DOM の作成から始めます。
javascriptclass ModalDialog extends HTMLElement {
constructor() {
super();
// Shadow DOM を作成してテンプレートを挿入
this.attachShadow({ mode: 'open' });
this.shadowRoot.appendChild(template.content.cloneNode(true));
// DOM 要素への参照を保持
this.overlay = this.shadowRoot.querySelector('.overlay');
this.dialog = this.shadowRoot.querySelector('.dialog');
this.closeBtn = this.shadowRoot.querySelector('.close-btn');
// 元々フォーカスがあった要素を記憶するための変数
this.previouslyFocusedElement = null;
}
コンストラクタでは、Shadow DOM を作成し、先ほど定義したテンプレートを挿入します。また、後で使う DOM 要素への参照を保持しておくことで、パフォーマンスを向上させています。
次に、要素が DOM に追加されたときの処理を定義します。
javascript connectedCallback() {
// イベントリスナーを設定
this.closeBtn.addEventListener('click', () => this.close());
this.overlay.addEventListener('click', (e) => {
// オーバーレイ自体がクリックされた場合のみ閉じる(ダイアログ内は除外)
if (e.target === this.overlay) {
this.close();
}
});
// Esc キーでモーダルを閉じる
this.handleKeydown = (e) => {
if (e.key === 'Escape' && this.overlay.classList.contains('show')) {
this.close();
}
};
document.addEventListener('keydown', this.handleKeydown);
}
connectedCallback
は、要素が DOM に追加されたときに自動的に呼ばれるライフサイクルメソッドです。ここで、閉じるボタンのクリック、オーバーレイのクリック、Esc キーの押下に対するイベントリスナーを設定しています。
また、要素が DOM から削除されたときにイベントリスナーを解除する処理も追加しましょう。
javascript disconnectedCallback() {
// メモリリークを防ぐためにイベントリスナーを削除
document.removeEventListener('keydown', this.handleKeydown);
}
モーダルを開く処理
モーダルを開く open()
メソッドでは、フォーカスの保存とフォーカストラップの有効化を行います。
javascript open() {
// 現在フォーカスされている要素を記憶
this.previouslyFocusedElement = document.activeElement;
// モーダルを表示
this.overlay.classList.add('show');
// フォーカストラップを有効化
this.enableFocusTrap();
// 最初のフォーカス可能な要素にフォーカスを移動
const firstFocusable = this.getFocusableElements()[0];
if (firstFocusable) {
firstFocusable.focus();
}
}
モーダルを開く前に、現在フォーカスされている要素を previouslyFocusedElement
に保存しておきます。これにより、モーダルを閉じたときに元の場所にフォーカスを戻せますね。
フォーカストラップの実装
フォーカストラップでは、Tab キーでのフォーカス移動を監視し、ダイアログ内のフォーカス可能な要素をループさせます。
javascript enableFocusTrap() {
this.focusTrapHandler = (e) => {
if (e.key !== 'Tab') return;
const focusableElements = this.getFocusableElements();
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
// Shift+Tab で最初の要素から戻ろうとした場合、最後の要素へ
if (e.shiftKey && document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
}
// Tab で最後の要素から進もうとした場合、最初の要素へ
else if (!e.shiftKey && document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
};
document.addEventListener('keydown', this.focusTrapHandler);
}
Tab キーが押されたとき、現在のフォーカス位置が最初または最後の要素であれば、ループするように制御しています。これにより、ユーザーがダイアログの外にフォーカスを移動できなくなるんですね。
次に、フォーカス可能な要素を取得するヘルパーメソッドを定義します。
javascript getFocusableElements() {
// Shadow DOM 内のフォーカス可能な要素を取得
const focusableSelectors = [
'button:not([disabled])',
'a[href]',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])'
];
return Array.from(
this.shadowRoot.querySelectorAll(focusableSelectors.join(','))
).filter(el => el.offsetParent !== null); // 非表示要素を除外
}
このメソッドでは、ボタン、リンク、入力欄など、フォーカス可能な要素をすべて取得しています。offsetParent
をチェックすることで、非表示になっている要素を除外しているのがポイントです。
モーダルを閉じる処理
モーダルを閉じる close()
メソッドでは、フォーカストラップを解除し、元の要素にフォーカスを戻します。
javascript close() {
// モーダルを非表示
this.overlay.classList.remove('show');
// フォーカストラップを解除
this.disableFocusTrap();
// 元々フォーカスがあった要素にフォーカスを戻す
if (this.previouslyFocusedElement) {
this.previouslyFocusedElement.focus();
}
// イベントを発火(外部で閉じた後の処理を実行できるように)
this.dispatchEvent(new CustomEvent('modal-closed'));
}
disableFocusTrap() {
// フォーカストラップのイベントリスナーを削除
if (this.focusTrapHandler) {
document.removeEventListener('keydown', this.focusTrapHandler);
this.focusTrapHandler = null;
}
}
}
モーダルを閉じたら、previouslyFocusedElement
に保存しておいた要素にフォーカスを戻します。これにより、ユーザーは元の操作位置から続けられるようになりますね。また、modal-closed
というカスタムイベントを発火させることで、外部から閉じた後の処理をフックできるようにしています。
カスタム要素の登録
最後に、定義したクラスをカスタム要素として登録します。
javascript// カスタム要素を登録
customElements.define('modal-dialog', ModalDialog);
これで、HTML 内で <modal-dialog>
タグを使えるようになりました。
動作確認
ブラウザで index.html
を開いて、「モーダルを開く」ボタンをクリックしてみましょう。モーダルが表示され、Tab キーでフォーカスがダイアログ内をループすること、Esc キーやオーバーレイクリックでモーダルが閉じることを確認できます。
以下の図は、ユーザーがモーダルダイアログを操作する際の状態遷移を示したものです。
mermaidstateDiagram-v2
[*] --> ModalClosed: 初期状態
ModalClosed --> ModalOpen: open() 呼び出し
ModalOpen --> FocusTrapped: フォーカストラップ有効化
FocusTrapped --> FocusTrapped: Tab キーでループ
FocusTrapped --> ModalClosing: ×ボタンクリック
FocusTrapped --> ModalClosing: Esc キー
FocusTrapped --> ModalClosing: オーバーレイクリック
ModalClosing --> ModalClosed: close() 実行<br/>フォーカス復帰
ModalClosed --> [*]
図の要点:モーダルは開く・フォーカストラップ・閉じるというライフサイクルを持ち、複数の閉じる動線から元の状態に戻ります。
カスタマイズ例
モーダルの内容は、スロットを使って自由にカスタマイズできます。たとえば、アクションボタンを追加する場合は以下のようにします。
html<modal-dialog id="myModal">
<h2 slot="title">ログアウト確認</h2>
<p slot="content">本当にログアウトしますか?</p>
<div slot="actions">
<button id="confirmBtn">ログアウト</button>
<button id="cancelBtn">キャンセル</button>
</div>
</modal-dialog>
スロットを使うことで、モーダルの見た目や機能を柔軟に変更できるのが Web Components の魅力ですね。
エラー対処例
エラー: Uncaught TypeError: Cannot read properties of null (reading 'focus')
発生条件:フォーカス可能な要素が見つからない場合に発生します。
解決方法:
getFocusableElements()
が空配列を返していないか確認するopen()
メソッドで以下のようにガードを追加する
javascriptconst firstFocusable = this.getFocusableElements()[0];
if (firstFocusable) {
firstFocusable.focus();
} else {
console.warn(
'モーダル内にフォーカス可能な要素が見つかりません'
);
}
- モーダル内に少なくとも 1 つのボタンやリンクを配置する
エラー: DOMException: Failed to execute 'define' on 'CustomElementRegistry'
エラーコード: DOMException
発生条件:同じ名前のカスタム要素を複数回登録しようとした場合に発生します。
解決方法:
- 登録前に既に定義されていないかチェックする
javascriptif (!customElements.get('modal-dialog')) {
customElements.define('modal-dialog', ModalDialog);
}
- スクリプトが複数回読み込まれていないか確認する
- モジュールスクリプト(
type="module"
)を使って重複を防ぐ
まとめ
本記事では、Web Components を使ってモーダルダイアログを実装する方法を、フォーカス管理や複数の閉じる動線を含めて詳しく解説いたしました。
実装のポイント
# | 項目 | 内容 |
---|---|---|
1 | フォーカストラップ | Tab キーでのフォーカス移動をダイアログ内に限定 |
2 | 複数の閉じる動線 | 閉じるボタン・Esc キー・オーバーレイクリックの 3 通り |
3 | フォーカスの復帰 | モーダルを閉じたら元の要素にフォーカスを戻す |
4 | ARIA 属性 | role="dialog" 、aria-modal="true" でアクセシビリティ対応 |
5 | Shadow DOM | スタイルをカプセル化して外部との衝突を防ぐ |
6 | スロット | モーダルの内容を外部から柔軟にカスタマイズ可能 |
これらのポイントを押さえることで、ユーザビリティとアクセシビリティに優れたモーダルダイアログを実装できます。
今後の拡張案
さらに機能を充実させたい場合は、以下のような拡張も検討できるでしょう。
- アニメーション: CSS トランジションでフェードイン・フェードアウトを追加
- 多段モーダル: モーダルの上にさらにモーダルを開けるようにする
- サイズバリエーション: 小・中・大のサイズオプションを属性で指定
- モバイル対応: スワイプダウンで閉じる動作を追加
Web Components は、一度作れば他のプロジェクトでも使いまわせる汎用性の高い資産になります。ぜひ、本記事のコードをベースに、自分のプロジェクトに合わせてカスタマイズしてみてくださいね。
関連リンク
- article
Web Components で作るモーダルダイアログ:フォーカス管理・閉じる動線まで実装
- article
Web Components の API 設計原則:属性 vs プロパティ vs メソッドの境界線
- article
Web Components スタイリング速見表:`::part`/`::slotted`/AdoptedStyleSheets(Constructable Stylesheets)
- article
Web Components を Vite + TypeScript + yarn で最短セットアップする完全手順
- article
Web Components 全体像を一枚図で理解する:Shadow DOM/slots/ElementInternals の関係
- article
Web Components Shadow DOM を使いこなす - スタイルカプセル化と Slot 活用テクニック
- article
Apollo キャッシュ操作チートシート:`cache.modify`/`writeQuery`/`readFragment` 早見表
- article
GitHub Actions 条件式チートシート:if/contains/startsWith/always/success/failure
- article
Zod で CSV/TSV インポートを安全に処理:パース → 検証 → 差分レポート
- article
Yarn のインストール完全ガイド:Corepack 有効化からバージョン固定まで
- article
Git を macOS に最適導入:Homebrew・初期設定テンプレ・credential 管理まで
- article
Web Components で作るモーダルダイアログ:フォーカス管理・閉じる動線まで実装
- blog
iPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
- blog
Googleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
- blog
【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
- blog
Googleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
- blog
Pixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
- blog
フロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
- review
今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
- review
ついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
- review
愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
- review
週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
- review
新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
- review
科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来