モーダルウィンドウの作成

以下では、DOM を用いてアクセシブルな「モーダルウィンドウ(オーバーレイダイアログ)」を実装する方法を、設計要件 → 最小実装(HTML/CSS/JS) → 動作の要点 → ベストプラクティスと拡張、の順で詳しく解説します。説明文中に絵文字は使用しません。

1. モーダルの目的と必須要件

モーダルは、ユーザーの注意を一時的に一点に集め、同時に背後のコンテンツ操作を停止させる UI です。実装上の必須事項は次のとおりです。

  • 背景の操作とフォーカス移動を遮断(フォーカストラップ)

  • Esc キーや閉じるボタンで確実に閉じられる

  • 開いたらモーダル内の適切な要素へ初期フォーカス、閉じたら元のフォーカスを復帰

  • スクリーンリーダー向けに role="dialog" / aria-modal="true"aria-labelledby/aria-describedby を付与

  • 背景スクロールをロック(必要に応じてスクロールバー幅の補正)

2. 最小実装(アクセスビリティ対応)

2.1 HTML

html
<!-- 操作用のトリガーボタン(どこに置いてもよい) --> <button type="button" data-open-modal="#exampleModal">開く</button> <!-- ページの主要領域。モーダル表示中は inert で無効化する想定 --> <main id="pageRoot"> <h1>ページ見出し</h1> <p>本文...</p> </main> <!-- モーダル本体(初期状態は hidden) --> <div id="exampleModal" class="modal" role="dialog" aria-modal="true" aria-labelledby="modalTitle" aria-describedby="modalDesc" hidden> <!-- 背景のオーバーレイ(クリックで閉じる) --> <div class="modal__overlay" data-close-modal></div> <!-- ダイアログ内容 --> <div class="modal__window" role="document"> <h2 id="modalTitle">設定</h2> <p id="modalDesc">通知設定を更新できます。</p> <!-- フォーム例 --> <label> <input type="checkbox" checked> 週次レポートを受け取る </label> <div class="modal__actions"> <button type="button" class="btn-primary">保存</button> <button type="button" data-close-modal>閉じる</button> </div> </div> </div>

2.2 CSS(レイアウトとトランジション)

css
/* 画面全体を覆うコンテナ(中央配置用のグリッド) */ .modal { position: fixed; inset: 0; display: grid; place-items: center; z-index: 1000; } /* 半透明の背景 */ .modal__overlay { position: absolute; inset: 0; background: rgba(0,0,0,0.45); } /* ダイアログ本体 */ .modal__window { position: relative; max-width: 560px; width: min(90vw, 560px); background: #fff; border-radius: 12px; padding: 20px; box-shadow: 0 20px 60px rgba(0,0,0,0.3); /* 簡易アニメーション(任意) */ transform: translateY(6px); opacity: 0; transition: opacity .2s ease, transform .2s ease; } /* 表示状態のトランジション(JS で .is-open を付与) */ .modal.is-open .modal__window { opacity: 1; transform: translateY(0); } .modal[hidden] { display: none; } .modal__actions { margin-top: 16px; display: flex; gap: 8px; justify-content: flex-end; } .btn-primary { border: none; background: #0b5ed7; color: #fff; padding: 8px 14px; border-radius: 8px; cursor: pointer; } .btn-primary:focus { outline: 2px solid #0b5ed7; }

2.3 JavaScript(開閉・フォーカストラップ・Esc・背景ロック)

html
<script> (() => { const pageRoot = document.getElementById('pageRoot'); // 汎用: モーダル内のフォーカス可能要素を列挙 const focusableSelector = [ 'a[href]', 'area[href]', 'input:not([disabled]):not([type="hidden"])', 'select:not([disabled])', 'textarea:not([disabled])', 'button:not([disabled])', 'iframe', 'audio[controls]', 'video[controls]', '[tabindex]:not([tabindex="-1"])', '[contenteditable="true"]' ].join(','); let lastActiveElement = null; let scrollBarCompensation = 0; function openModal(modal) { if (!modal || !modal.hasAttribute('hidden')) return; // 1) 直前のフォーカスを保存 lastActiveElement = document.activeElement; // 2) 背景をフォーカス不可に(inert が使えなければ aria-hidden 併用) if ('inert' in HTMLElement.prototype) { pageRoot.inert = true; } else { pageRoot.setAttribute('aria-hidden', 'true'); } // 3) 背景スクロールのロック(スクロールバー補正) const hasVScroll = document.documentElement.scrollHeight > document.documentElement.clientHeight; if (hasVScroll) { const prevOverflowY = getComputedStyle(document.body).overflowY; // 既にロックされていないときのみ補正 if (prevOverflowY !== 'hidden') { const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; scrollBarCompensation = scrollbarWidth > 0 ? scrollbarWidth : 0; if (scrollBarCompensation) { document.body.style.paddingRight = `${scrollBarCompensation}px`; } } } document.body.style.overflow = 'hidden'; // 4) 表示 modal.hidden = false; // トランジション用クラス requestAnimationFrame(() => modal.classList.add('is-open')); // 5) 初期フォーカス(閉じるボタンなど明確なターゲットが望ましい) const focusables = modal.querySelectorAll(focusableSelector); const target = focusables[0] || modal.querySelector('.modal__window') || modal; target.focus({ preventScroll: true }); // 6) キー操作・クリックをバインド modal.addEventListener('keydown', trapTab); modal.addEventListener('keydown', onEsc); modal.addEventListener('click', onOverlayClick); } function closeModal(modal) { if (!modal || modal.hasAttribute('hidden')) return; modal.classList.remove('is-open'); // トランジション終了後に完全非表示 const onTransitionEnd = () => { modal.hidden = true; modal.removeEventListener('transitionend', onTransitionEnd); }; modal.addEventListener('transitionend', onTransitionEnd); // 解除 if ('inert' in HTMLElement.prototype) { pageRoot.inert = false; } else { pageRoot.removeAttribute('aria-hidden'); } document.body.style.overflow = ''; if (scrollBarCompensation) { document.body.style.paddingRight = ''; scrollBarCompensation = 0; } modal.removeEventListener('keydown', trapTab); modal.removeEventListener('keydown', onEsc); modal.removeEventListener('click', onOverlayClick); // 元のフォーカスを復帰 if (lastActiveElement && typeof lastActiveElement.focus === 'function') { lastActiveElement.focus({ preventScroll: true }); } } // Tab/Shift+Tab をモーダル内に閉じ込める function trapTab(e) { if (e.key !== 'Tab') return; const modal = e.currentTarget; const focusables = [...modal.querySelectorAll(focusableSelector)].filter(el => el.offsetParent !== null || el === document.activeElement); if (focusables.length === 0) { e.preventDefault(); modal.querySelector('.modal__window')?.focus(); return; } const first = focusables[0]; const last = focusables[focusables.length - 1]; const isShift = e.shiftKey; if (!isShift && document.activeElement === last) { e.preventDefault(); first.focus(); } else if (isShift && document.activeElement === first) { e.preventDefault(); last.focus(); } } // Esc で閉じる function onEsc(e) { if (e.key === 'Escape') { closeModal(e.currentTarget); } } // オーバーレイクリックで閉じる(ウィンドウ外クリック) function onOverlayClick(e) { const modal = e.currentTarget; const win = modal.querySelector('.modal__window'); if (!win.contains(e.target)) { closeModal(modal); } } // 開くボタン document.addEventListener('click', (e) => { const trigger = e.target.closest('[data-open-modal]'); if (!trigger) return; const selector = trigger.getAttribute('data-open-modal'); const modal = document.querySelector(selector); openModal(modal); }); // 閉じるボタン(属性でマーク) document.addEventListener('click', (e) => { const closer = e.target.closest('[data-close-modal]'); if (!closer) return; const modal = e.target.closest('.modal'); closeModal(modal); }); })(); </script>

3. DOM 操作のポイント解説

  • 表示/非表示は hidden 属性で制御し、CSS の display: none として機能させています。視覚・スクリーンリーダー双方で確実に非表示になります。

  • フォーカストラップ:keydown の Tab をフックし、モーダル内のフォーカス可能要素を列挙して先頭↔末尾で循環させています。

  • 初期フォーカス/復帰:開く前の document.activeElement を保存し、閉じたら戻します。開いた直後は確実に操作可能なボタン等へフォーカスします。

  • 背景無効化:inert が使える環境では pageRoot.inert = true で背後のフォーカス移動とクリックを抑止します。非対応環境向けに aria-hidden を併用しています。

  • Esc/オーバーレイクリック:閉じる経路を複数用意します。role=”dialog” かつ aria-modal=”true” でスクリーンリーダーにモーダル状態を通知します。

  • スクロールロック:body { overflow: hidden }。スクロールバー消失で横幅がずれる場合は、消失分の幅を padding-right に加算してレイアウトシフトを抑えています。

4. アクセシビリティの要点チェックリスト

  • role=”dialog”, aria-modal=”true”

  • aria-labelledby(見出し要素の id を参照)と aria-describedby(説明要素の id)

  • 初期フォーカスを設定し、Tab でモーダル内を循環

  • Esc で閉じられる

  • 背景コンテンツはフォーカス不可(inert、なければ aria-hidden)

  • 閉じたら元のフォーカスへ復帰

5. よくある拡張・発展

  1. アニメーション
    開閉時に is-open クラスをトグルし、CSS の opacity / transform、transition で柔らかく表示。閉じるときは transitionend を待ってから hidden を付与。

  2. 複数モーダル
    data-open-modal=”#id” の仕組みで任意のモーダルを開けます。スタック(入れ子)を許可する場合は、背後のモーダルに aria-hidden を付与し、フォーカスマネジメントを一段上書きする必要があります。基本は入れ子を避ける設計が無難です。

  3. 閉じた理由の把握
    保存/キャンセル/オーバーレイクリック/Esc など、原因ごとにハンドラで分岐してイベントを発火させると、呼び出し側で振る舞いを切り替えられます。

  4. 動的な内容差し替え
    innerHTML で差し込む場合は信頼できるコンテンツのみに限定し、ユーザー入力はエスケープして XSS を防止します。理想的には DOM API(createElement 等)で要素を構築します。

  5. フォーム内スクロール
    モーダル内容が長い場合は .modal__window { max-height: 90vh; overflow: auto; } を付与すると、背景を固定したまま内部のみスクロールできます。

6. 別解:ネイティブ <dialog> 要素で作る最小例

モダンブラウザでは <dialog> の showModal()/close() が使えます。フォーカストラップや背景ロックが組み込みで扱いやすく、簡素です。

html
<button id="openDlg">開く</button> <dialog id="dlg" aria-labelledby="dlgTitle" aria-describedby="dlgDesc"> <h2 id="dlgTitle">設定</h2> <p id="dlgDesc">通知設定を更新できます。</p> <form method="dialog"> <button value="save">保存</button> <button value="cancel">閉じる</button> </form> </dialog> <script> const dlg = document.getElementById('dlg'); document.getElementById('openDlg').addEventListener('click', () => dlg.showModal()); dlg.addEventListener('cancel', (e) => { // Esc で閉じられたときのデフォルト動作。必要なら e.preventDefault() で阻止可能 }); dlg.addEventListener('close', () => { // dlg.returnValue で "save" などの結果が取れる console.log('closed with:', dlg.returnValue); }); </script> <style> dialog::backdrop { background: rgba(0,0,0,.45); } dialog { border: none; border-radius: 12px; padding: 20px; } </style>

<dialog> は簡潔ですが、ブラウザ実装差異や既存の CSS レイアウトとの整合が必要になる場合があります。細かな制御が必要なら前節のカスタム実装が柔軟です。

7. まとめ(実装の指針)

  • まずは アクセシビリティ要件(ロール、初期フォーカス、Esc、フォーカストラップ、背景無効化)を満たす。

  • 視覚表現は オーバーレイ + ウィンドウ の二層構造にし、アニメーションはクラス切り替えで制御。

  • スクロールロックとフォーカス復帰で、開閉時の違和感を最小化。

  • 拡張は後付け可能な設計(閉じる理由の通知、バリデーション、ボタンのローディング状態など)にする。

この流れに沿って実装すれば、実運用で問題が起きにくい堅牢なモーダルを DOM だけで構築できます。

ChatGPT5 生成日:2025/09/11