まずはモーダルウィンドウの動きを確認する

前回の「タブ切り替えの記事」では、data属性を使ってタブとパネルを関連付け、選択状態と表示状態をまとめて更新しました。

今回は、画面の上に重ねて情報を表示する「モーダルウィンドウ」を作ります。

モーダルでは、単に要素を表示・非表示にするだけでなく、背景の操作を止める、Escapeキーで閉じる、開閉前後のフォーカス位置を管理するといった対応も必要です。

今回はHTMLのdialog要素を利用し、モーダルの基本的な開閉から、背景クリックとフォーカス管理まで順番に確認します。

一般的なモーダルウィンドウは、次のように動作します。

  1. 「モーダルを開く」ボタンをクリックする
  2. 画面の上にモーダルが表示される
  3. 背景部分が暗くなり、背後のページを操作できなくなる
  4. モーダル内の閉じるボタンで閉じる
  5. 背景部分のクリックでも閉じる
  6. Escapeキーを押しても閉じる
  7. 閉じたあとは、モーダルを開いたボタンへフォーカスが戻る

完成イメージ

ボタンを押して、閉じるボタン、背景クリック、Escapeキーの動作を確認してください。

Modal Window

モーダルのタイトル

モーダル内に表示する内容です。

今回作る機能を確認する

今回の基本コードでは、次の機能を実装します。

  • ボタンをクリックしてモーダルを開く
  • モーダル内のボタンで閉じる
  • 背景部分をクリックして閉じる
  • Escapeキーで閉じる
  • autofocusで、開いた直後のフォーカス位置を指定する
  • dialogの標準動作による、閉じたあとのフォーカス復帰を確認する

モーダルを開いたときに変わるものを整理する

モーダルが開いている間は、単に見た目が変わるだけではありません。 「ユーザーの操作対象が、ページ全体からモーダルの中だけに制限される」という状態の変化が起きます。そのため、背後のリンクをクリックできないようにしたり、キーボードのTabキーによる移動をモーダル内に閉じ込めたりする制御が必要になります。

dialog要素を使う理由

以前は、これらすべての制御をdiv要素と複雑なJavaScriptを組み合わせて自作する必要がありました。 しかし現在では、HTMLに標準で用意されているdialog要素を使うことで、背景の操作無効化やフォーカス制御の多くをブラウザが自動で行ってくれます。この記事でも、ブラウザの標準機能を利用でき、div要素で自作するより実装を簡潔にしやすいdialog要素を使用します。

背景スクロールに関する注意

showModal()で開くと、背後のページはクリックやフォーカスの対象外になります。 ただし、背景ページのスクロール固定は別の問題です。モーダルのサイズやスマートフォンの環境によっては、必要に応じてスクロール固定を追加することがあります。今回は、背景要素を操作できない状態とフォーカス管理を中心に扱います。

この記事で学べること

  • dialog要素の基本と、専用メソッドshowModal()close()の使い方
  • 要素の外側(背景部分)がクリックされたことを判定する方法
  • cancelイベントを使ったEscapeキーによる閉じる要求の扱い
  • dialogを開いたときの初期フォーカスとautofocus属性の使い方
  • dialogを閉じたときに元の操作位置へ戻る標準動作

モーダルのHTMLを書く

まずはベースとなるHTMLです。モーダルの枠組みとして<dialog>を使用します。 dialog要素自体がダイアログとしての意味を持つため、基本コードではrole="dialog"を重ねて付ける必要はありません。

<button
  class="modal-open-button js-modal-open"
  type="button"
  aria-haspopup="dialog"
  aria-controls="sample-modal"
>
  モーダルを開く
</button>

<dialog
  id="sample-modal"
  class="modal js-modal"
  aria-labelledby="sample-modal-title"
>
  <div class="modal__content">
    <button
      class="modal__close js-modal-close"
      type="button"
      aria-label="モーダルを閉じる"
      autofocus
    >
      ×
    </button>

    <h2 id="sample-modal-title">
      モーダルのタイトル
    </h2>

    <p>
      モーダル内に表示する内容です。
    </p>

    <button
      class="modal__button js-modal-close"
      type="button"
    >
      閉じる
    </button>
  </div>
</dialog>

CSSでモーダルと背景を整える

dialog要素にはブラウザのデフォルトスタイルが当たっているため、これをリセットしつつデザインを整えます。 暗くなる背景部分は、専用の疑似要素::backdropを使ってスタイリングします。

.modal {
  width: min(90%, 560px);
  padding: 0;
  border: 0;
  border-radius: 16px;
  background: transparent;
}

.modal::backdrop {
  background: rgba(15, 23, 42, 0.7);
}

.modal__content {
  position: relative;
  padding: 40px;
  border-radius: 16px;
  background: #fff;
  color: #0f172a;
}

.modal__close {
  position: absolute;
  top: 16px;
  right: 16px;
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  border: 0;
  border-radius: 50%;
  background: #e2e8f0;
  cursor: pointer;
}

.modal__close:focus-visible,
.modal__button:focus-visible {
  outline: 3px solid rgba(37, 99, 235, 0.45);
  outline-offset: 3px;
}

JavaScriptでモーダルを開く

ここからはJavaScriptです。まずはボタンをクリックしてモーダルを開く処理を作ります。 ここではmodal.showModal()というdialog要素専用のメソッドを使います。

document.addEventListener('DOMContentLoaded', () => {
  const openButton = document.querySelector('.js-modal-open');
  const modal = document.querySelector('.js-modal');

  if (!openButton || !modal) return;

  const openModal = () => {
    if (!modal.open) {
      modal.showModal();
    }
  };

  openButton.addEventListener('click', openModal);
  
  // 以降のコードは後述します
});

閉じるボタンでモーダルを閉じる

開いたモーダルを閉じるには、modal.close()メソッドを使います。モーダル内にある複数の閉じるボタン(×ボタンとテキストボタン)を取得し、それぞれに処理を登録します。

const closeButtons = modal.querySelectorAll('.js-modal-close');

const closeModal = () => {
  if (modal.open) {
    modal.close();
  }
};

closeButtons.forEach((button) => {
  button.addEventListener('click', closeModal);
});

背景をクリックしたら閉じる

::backdropで暗くなっている背景部分をクリックしたときにも閉じるようにします。 実は背景部分はdialog要素そのものとして判定されます。そのため、クリックされた要素(event.target)がモーダルの中身ではなくdialog自身であった場合に、閉じる処理を実行します。

modal.addEventListener('click', (event) => {
  if (event.target === modal) {
    closeModal();
  }
});

Escapeキーを押したら閉じる

dialog要素は、標準でもEscapeキーによって閉じられます。 今回は、閉じる処理をcloseModalへ統一するため、cancelイベントのデフォルト動作をpreventDefault()で止め、共通の関数を呼び出しています。

modal.addEventListener('cancel', (event) => {
  event.preventDefault();
  closeModal();
});

モーダルを開いたときのフォーカス位置を決める

dialog要素をshowModal()で開くと、ブラウザがモーダル内へフォーカスを移します。

ただし、モーダル内に複数のボタンや入力欄がある場合は、最初にどこを操作してほしいかを明確にしておくことが大切です。

今回は、右上の閉じるボタンへautofocus属性を付け、モーダルを開いた直後の操作位置を指定します。

<button
  class="modal__close js-modal-close"
  type="button"
  aria-label="モーダルを閉じる"
  autofocus>
  ×
</button>

モーダルが開いたときはフォーカスが内部へ移動し、Tab・Shift+Tabによる移動もモーダル内に留まるのがモーダルダイアログの基本動作です。

閉じたあとに元の操作位置へ戻る

モーダルを閉じたあとは、モーダルを開いたボタンへフォーカスを戻すことが基本です。

dialog要素をshowModal()で開くと、ブラウザは開く前にフォーカスされていた要素を記録します。モーダルをclose()で閉じると、通常はその要素へフォーカスが戻ります。

そのため、今回の基本コードでは、document.activeElementの保存やfocus()による復帰処理を追加する必要はありません。

モーダル内の処理後に別の場所へ移動させたい場合など、標準とは異なる動きが必要なときは、closeイベント内でfocus()を使用します。HTML仕様でも、showModal()時に直前のフォーカス要素を記録し、閉じる処理でその要素へフォーカスを戻す手順が定められています。

コードの意味を1行ずつ見る

showModalでモーダルを開く

modal.showModal();

モーダルを画面の最前面に表示し、同時に背景の操作を無効化します。単にダイアログを表示するshow()というメソッドもありますが、こちらはモードレス表示のため背景側を操作できてしまうため、今回は使用しません。

closeでモーダルを閉じる

modal.close();

表示されているモーダルを閉じます。

event.targetで背景クリックを判定する

if (event.target === modal)

::backdrop上で発生したクリックはdialog自身を対象として扱うため、これがtrueになります。モーダルの中身である白い部分をクリックした場合はfalseになるため、誤って閉じてしまうことを防げます。

cancelイベントでEscapeキーを扱う

modal.addEventListener('cancel', ...)

cancelイベントはEscapeキーなどによるプラットフォームからの「閉じる要求」で発生します。preventDefault()で標準の閉じる動作を止め、今回はその後に共通のcloseModal()を呼ぶ構成にしています。

autofocusで最初の操作位置を指定する

<button type="button" autofocus>

モーダルを開いた直後に、どの要素へフォーカスを移すかをHTMLで指定します。入力フォームなら最初に入力する欄、確認画面なら安全な選択肢など、モーダルの目的に合わせて指定します。

完成コード

ここまでの実装をまとめた完成コードです。HTML、CSS、JavaScriptの3つに分けて掲載します。フォーカス管理の多くをブラウザの標準機能に任せているため、JavaScriptも簡潔にまとめられます。

ここでは、モーダルの仕組みを確認しやすいように、必要な部分へ絞った完成コードを掲載します。CodePenでは、同じ仕組みにサンプルページ用の装飾やスマートフォン向けの調整を加えています。

<link rel="stylesheet" href="./style.css">
<script src="./script.js" defer></script>

<button
  class="modal-open-button js-modal-open"
  type="button"
  aria-haspopup="dialog"
  aria-controls="sample-modal"
>
  モーダルを開く
</button>

<dialog
  id="sample-modal"
  class="modal js-modal"
  aria-labelledby="sample-modal-title"
>
  <div class="modal__content">
    <button
      class="modal__close js-modal-close"
      type="button"
      aria-label="モーダルを閉じる"
      autofocus
    >
      ×
    </button>

    <h2 id="sample-modal-title">
      モーダルのタイトル
    </h2>

    <p>
      モーダル内に表示する内容です。
    </p>

    <button
      class="modal__button js-modal-close"
      type="button"
    >
      閉じる
    </button>
  </div>
</dialog>
.modal {
  width: min(90%, 560px);
  padding: 0;
  border: 0;
  border-radius: 16px;
  background: transparent;
}

.modal::backdrop {
  background: rgba(15, 23, 42, 0.7);
}

.modal__content {
  position: relative;
  padding: 40px;
  border-radius: 16px;
  background: #fff;
  color: #0f172a;
}

.modal__close {
  position: absolute;
  top: 16px;
  right: 16px;
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  border: 0;
  border-radius: 50%;
  background: #e2e8f0;
  cursor: pointer;
}

.modal__close:focus-visible,
.modal__button:focus-visible {
  outline: 3px solid rgba(37, 99, 235, 0.45);
  outline-offset: 3px;
}
(() => {
  const openButton = document.querySelector('.js-modal-open');
  const modal = document.querySelector('.js-modal');

  if (!openButton || !modal) {
    return;
  }

  const closeButtons = modal.querySelectorAll('.js-modal-close');

  const openModal = () => {
    if (!modal.open) {
      modal.showModal();
    }
  };

  const closeModal = () => {
    if (modal.open) {
      modal.close();
    }
  };

  openButton.addEventListener('click', openModal);

  closeButtons.forEach((button) => {
    button.addEventListener('click', closeModal);
  });

  modal.addEventListener('click', (event) => {
    if (event.target === modal) {
      closeModal();
    }
  });

  modal.addEventListener('cancel', (event) => {
    event.preventDefault();
    closeModal();
  });
})();

実際に触ってみよう

完成コードは、下のCodePenでも確認できます。閉じるボタン、背景クリック、Escapeキーによる閉じる操作に加え、モーダルを開いたときと閉じたときのフォーカス位置も確認してください。

See the Pen Web制作JS|モーダルウィンドウ|基本サンプル by WebToolGen (@webtoolgen) on CodePen.

実務で追加したい発展対応

複数のボタンから同じモーダルを開く

「ヘッダーのボタン」と「記事下のボタン」の両方から同じモーダルを開きたい場合は、querySelectorAllで開くボタンをすべて取得し、forEachでそれぞれにopenModalを登録するように変更します。

複数のモーダルを同じページに設置する

モーダルA、モーダルBのように複数設置する場合は、前回の「タブ切り替え」で学んだように、data属性(data-modal-targetなど)を使って「どのボタンがどのモーダルを開くか」をJavaScriptで紐付ける処理が必要になります。

フォーム送信後にモーダルを閉じる

モーダル内に問い合わせフォームなどを設置する場合、送信ボタンが押されて通信が成功したタイミングで、JavaScriptからcloseModal()を呼び出して自動的に閉じると親切です。

よくある失敗と解決法

ボタンを押してもモーダルが開かない

JavaScriptのエラーが出ていないか確認しましょう。また、CSSでdialog要素に対して明示的にdisplay: none;などを設定していると、showModal()を実行しても表示されないことがあります。

背景をクリックしても閉じない

モーダルの中身が<dialog>に直接書かれていて隙間がない場合などに発生します。中身を囲う.modal__contentなどのラップ要素を用意し、paddingなどを調整してください。

モーダルの後ろをクリックできてしまう

JavaScriptでmodal.show()というメソッドを使っていませんか?背景の操作を無効化したい場合は、必ずmodal.showModal()を使用します。

dialogdisplay: noneで非表示にしている

dialog要素は、ブラウザが標準で「閉じている時は非表示、開いている時は表示」の制御を行ってくれます。CSSで無理にdisplay: none;opacity: 0;にして自力で管理しようとすると、かえってバグの原因になります。

アクセシビリティで確認したいこと

モーダルの見出しをaria-labelledbyで関連付ける

モーダル内の見出し(<h2>など)のIDを、<dialog>aria-labelledbyに指定することで、スクリーンリーダーが「何のモーダルが開いたか」を正しく読み上げてくれます。

開いた直後のフォーカス位置を決める

dialog要素を開くとブラウザがモーダル内へフォーカスを移します。最初に操作してほしい要素が決まっている場合は、autofocus属性を付けて明示します。モーダルの内容によって、閉じるボタン、入力欄、安全な選択肢などから適切な要素を選びます。

閉じたあとに元のボタンへフォーカスを戻す

モーダルを閉じたあとは、通常、dialogの標準動作によってモーダルを開いた要素へフォーカスが戻ります。処理後に別の場所へ移動させる必要がある場合だけ、JavaScriptでフォーカス位置を調整します。

閉じる操作を複数用意する

「右上の×ボタン」「一番下の閉じるボタン」「背景クリック」「Escapeキー」と、マウスやキーボードなど様々な操作方法に配慮して複数の閉じる手段を用意します。 ただし、背景クリックで閉じる動作はすべてのモーダルに必要とは限りません。確認や入力を伴うモーダルでは、誤操作を避けるため、背景クリックでは閉じない設計も検討します。

dialogに不要なroleを追加しない

HTML5の<dialog>要素にはすでに暗黙のrole="dialog"が含まれています。冗長な記述は避け、シンプルに保ちましょう。

この記事のまとめ

今回は、dialog要素を使ってモーダルウィンドウを作りました。

showModal()close()による開閉、背景クリックの判定、cancelイベントを使ったEscapeキーへの対応、autofocusによる初期フォーカスの指定を確認しました。

dialog要素を利用すると、モーダル外の操作制限やフォーカス移動など、モーダルに必要な多くの処理をブラウザの標準機能に任せられます。

ここまでに扱った要素の取得、イベント処理、属性の設定、フォーカス管理は、さまざまなUIを組み立てる際の基礎になります。

完成コードを実際に操作しながら、モーダルを開いたときと閉じたときに、画面上の状態やフォーカス位置がどのように変わるか確認してみましょう。