はじめに

弊社では、主に以下のような技術構成でWebアプリケーション・Webサイトを開発しています。

  • PHP
  • TypeScript / JavaScript
  • SCSS
  • Vite

ReactやVueなどのフロントエンドフレームワークを全面的に採用している構成ではなく、HTML・CSS・JavaScriptを使ってUIを実装する場面も多くあります。

その中でも、検索フォームや確認画面、お知らせなどをモーダルで表示する機会はよくあります。

モーダルというと、<div> などの要素にCSSでスタイルを当て、JavaScriptで表示・非表示を切り替える実装を思い浮かべる方も多いのではないでしょうか。

一方、HTMLにはダイアログを表現するための <dialog> 要素が用意されています。

私自身、実務の中で <dialog> をベースにモーダルを実装する機会が増え、使っていく中で、

  • z-index の重なり順を細かく考える機会を減らせる
  • 背景部分を ::backdrop で実装できる
  • Escキーによるクローズなど、ブラウザ標準の挙動を利用できる
  • モーダルの開閉方法を統一しやすい

といったメリットを感じるようになりました。

最近ではAIを使ってコードを書く機会も増え、こうした細かな実装を一から考える場面は以前より少なくなっています。

それでも、ブラウザが標準でどのような機能を持っているのかを知っておくことで、生成されたコードが必要以上に複雑になっていないかを判断したり、よりシンプルな実装を選択したりしやすくなります。

この記事では、

  • <div> などを使って自作するモーダル
  • HTMLの <dialog> を使ったモーダル

をできるだけ同じ構成で実装しながら、それぞれのメリット・デメリットや、実務で使って感じたポイントを紹介します。

こんな方におすすめです

  • HTML・CSS・JavaScriptを使ってUIを実装することが多い方
  • モーダルを <div> などで自作したことがある方
  • <dialog> は知っているものの、実際にはあまり使ったことがない方
  • ブラウザ標準の機能を活用してUI実装をシンプルにしたい方
  • モーダルの実装方法をプロジェクト内で統一したい方

そもそもモーダルとは?

モーダルとは、画面の上に別のUIを表示し、その操作が完了するまで背後にあるコンテンツの操作を制限するUIです。

実務で使用するパターンとしては、

  • 検索フォーム
  • データ削除などの確認画面
  • お知らせ機能
  • 重要なメッセージをページ読み込み時に表示する
  • フォームの入力・編集

などがあります。

一見すると「画面の上に要素を表示するだけ」に見えますが、実際には、

  • どの要素より前面に表示するか
  • 背景をどのように暗くするか
  • モーダル表示中に背景を操作させるか
  • 背景クリックで閉じるか
  • Escキーで閉じるか
  • モーダルの開閉方法をどのように統一するか

など、意外と考えることがあります。

今回は、まず <div> で自作した場合を見たうえで、ほぼ同じ構成を <dialog> に置き換えて比較してみます。


<div> を使ってモーダルを自作する

まずは、<div> を使ってモーダルを作る場合です。

後ほど紹介する <dialog> 版と比較しやすいように、HTMLの構成はできるだけ同じ形にしています。

<button class="js-buttonOpen" data-dialog="deleteModal">
  削除する
</button>

<div
  class="js-dialogModal dialogModal -delete"
  id="deleteModal"
>
  <div class="js-dialogModalWrap dialogModal__wrap">
    <div class="dialogModal__header">
      <p class="dialogModal__ttl">
        本当に削除しますか?
      </p>
    </div>

    <div class="dialogModal__body">
      <button class="js-cancel">
        キャンセル
      </button>

      <button class="js-delete">
        削除する
      </button>
    </div>
  </div>
</div>

開くボタンの data-dialog には、対象となるモーダルの id を指定しています。

data-dialog="deleteModal"

対応するモーダルには、

id="deleteModal"

を設定します。

また、モーダルの中身を .js-dialogModalWrap で囲っています。

これは、背景部分をクリックしたときにはモーダルを閉じ、モーダルのコンテンツ内をクリックした場合は閉じないようにするための判定にも利用できます。

CSSでは、モーダルを画面全体に固定し、重なり順や背景も自分たちで指定します。

.dialogModal {
  position: fixed;
  inset: 0;
  z-index: 1000;

  display: none;
  place-items: center;

  background: rgb(0 0 0 / 50%);
}

.dialogModal.is-open {
  display: grid;
}

.dialogModal__wrap {
  background: #fff;
}

JavaScriptでは、data-dialog の値から対象のモーダルを取得し、クラスを付け外しすることで開閉します。

const openButton = document.querySelector(".js-buttonOpen");

const modalId = openButton.dataset.dialog;
const modal = document.getElementById(modalId);

const modalWrap = modal.querySelector(".js-dialogModalWrap");
const cancelButton = modal.querySelector(".js-cancel");

openButton.addEventListener("click", () => {
  modal.classList.add("is-open");
});

cancelButton.addEventListener("click", () => {
  modal.classList.remove("is-open");
});

modal.addEventListener("click", (event) => {
  if (modalWrap.contains(event.target)) {
    return;
  }

  modal.classList.remove("is-open");
});

.js-dialogModalWrap の中をクリックした場合は何もせず、その外側をクリックした場合のみモーダルを閉じています。

シンプルなモーダルであれば、このような実装でも十分作ることができます。

また、自作モーダルの大きなメリットは、自分たちで自由に制御できることです。

表示位置やアニメーション、背景、閉じるタイミング、重なり順などを、自分たちのルールに合わせて作ることができます。

特殊なデザインや独自の挙動が必要な場合には、自作の方が扱いやすいケースもあります。

自由度が高いからこその難しさ

一方で、自由度が高いということは、自分たちで管理するものも増えるということです。

例えば、モーダルを他の要素より前に表示したい場合、

.dialogModal {
  z-index: 1000;
}

のような指定をすることがあります。

しかし、プロジェクトが大きくなってくると、

.header {
  z-index: 100;
}

.menu {
  z-index: 500;
}

.dialogModal {
  z-index: 1000;
}

.anotherModal {
  z-index: 9999;
}

のように、さまざまな場所で z-index が使われ始めることがあります。

さらに別の画面で、

z-index: 99999;

のような指定が追加されていくと、

なぜこの値になっているのか?

どの要素より上に表示したかったのか?

が分かりにくくなってきます。

小規模なWebサイトであればそれほど問題にならなくても、長期間運用するWebアプリケーションや、多くのファイルを扱うプロジェクトでは管理が難しくなることがあります。

また、自作の場合はモーダルごとに実装方法を自由に決められるため、

  • Aのモーダルは背景クリックで閉じる
  • BのモーダルはEscキーで閉じる
  • Cのモーダルは独自のJavaScriptで開閉する

といったように、実装ルールがばらつく可能性もあります。


<dialog> を使ってモーダルを作る

次に、先ほどとほぼ同じ構成を <dialog> を使って実装してみます。

<button class="js-buttonOpen" data-dialog="deleteModal">
  削除する
</button>

<dialog
  class="js-dialogModal dialogModal -delete"
  id="deleteModal"
>
  <div class="js-dialogModalWrap dialogModal__wrap">
    <div class="dialogModal__header">
      <p class="dialogModal__ttl">
        本当に削除しますか?
      </p>
    </div>

    <div class="dialogModal__body">
      <button class="js-cancel">
        キャンセル
      </button>

      <button class="js-delete">
        削除する
      </button>
    </div>
  </div>
</dialog>

<div> 版との大きな違いは、モーダル本体が、

<div>

ではなく、

<dialog>

になったことです。

JavaScriptでは、モーダルを開くために showModal()、閉じるために close() を使用します。

const openButton = document.querySelector(".js-buttonOpen");

const dialogId = openButton.dataset.dialog;
const dialog = document.getElementById(dialogId);

const dialogWrap = dialog.querySelector(".js-dialogModalWrap");
const cancelButton = dialog.querySelector(".js-cancel");

openButton.addEventListener("click", () => {
  dialog.showModal();
});

cancelButton.addEventListener("click", () => {
  dialog.close();
});

dialog.addEventListener("click", (event) => {
  if (dialogWrap.contains(event.target)) {
    return;
  }

  dialog.close();
});

自作版では、

modal.classList.add("is-open");

としていた部分が、

dialog.showModal();

になります。

閉じる場合も、

modal.classList.remove("is-open");

ではなく、

dialog.close();

を利用します。

背景クリックの判定については自作版とほぼ同じ考え方で、.js-dialogModalWrap の中でクリックされた場合は何もせず、それ以外の場合のみ close() を実行しています。

HTMLの構造やクリック判定の考え方は大きく変えずに、モーダルとしての開閉をブラウザ標準の仕組みに置き換えることができます。


<dialog> を使うメリット

Top Layerに表示される

個人的に <dialog> を使っていて特に便利だと感じるのが、Top Layerに表示されることです。

showModal() で表示した <dialog> は、通常のDOM上の重なり順とは別に、ブラウザが管理するTop Layerへ表示されます。

そのため、

z-index: 999999;

のように大きな値を設定して、

このモーダルは他の要素より上に表示されるだろうか?

と考える機会を減らすことができます。

自作モーダルでは z-index を自由に設定できる反面、その重なり順も自分たちで管理する必要があります。

<dialog> では、その部分をブラウザ側に任せられるのが大きなメリットです。


::backdrop が使える

モーダルを表示すると、背景を半透明の黒などで覆うことが多いと思います。

<dialog> を showModal() で表示した場合は、::backdrop 疑似要素を利用できます。

.dialogModal::backdrop {
  background: rgb(0 0 0 / 50%);
}

例えばダイアログ本体は、

.dialogModal {
  padding: 0;
  border: 0;
}

としておき、背景部分だけを ::backdrop でスタイリングできます。

自作モーダルでは、モーダル本体や別の背景用要素に背景色を設定するケースがありますが、<dialog> では背景部分を専用の疑似要素として扱えます。


Escキーで閉じる処理が標準で用意されている

showModal() で表示した <dialog> は、Escキーによるクローズに標準で対応しています。

自作の場合、同じ挙動を実装するのであれば、

document.addEventListener("keydown", (event) => {
  if (event.key === "Escape") {
    modal.classList.remove("is-open");
  }
});

のような処理を追加することがあります。

<dialog> では、こうした基本的な挙動をブラウザ側に任せることができます。

ただし、この挙動はメリットである一方、要件によっては注意が必要です。

こちらについては後ほど紹介します。


背景の操作を制限できる

showModal() で開いた <dialog> では、ダイアログ以外のページコンテンツが操作できない状態になります。

自作の場合は、モーダルを画面全体に表示しただけでは、背景側の要素に対する操作についても自分たちで考える必要があります。

<dialog> をモーダルとして利用すると、こうしたモーダルの基本的な仕組みの一部をブラウザ側に任せることができます。


開閉方法を統一しやすい

実務で使っていてもう一つ便利だと感じるのが、開閉方法を統一しやすいことです。

モーダルを開く場合は、

dialog.showModal();

閉じる場合は、

dialog.close();

という共通の方法があります。

自作の場合は、

classList.add("is-open");

を使うのか、

style.display = "block";

を使うのかなど、プロジェクト側でルールを決める必要があります。

もちろん自作でもルールを決めて共通化できますが、<dialog> では最初から開閉用のAPIが用意されているため、それを基準に実装を揃えやすくなります。


<dialog> を使うときに気をつけたいこと

ここまでを見ると、

モーダルなら全部 <dialog> にすればよいのでは?

と思うかもしれません。

実際に使ってみると便利な機能は多いですが、ブラウザ標準の挙動があるからこそ注意したい部分もあります。


Escキーで閉じてほしくない場合は対策が必要

先ほどメリットとして紹介したEscキーによるクローズですが、モーダルによっては閉じてほしくないケースもあります。

例えば、

  • 必ず確認してほしい重要なメッセージ
  • 特定の操作を完了するまで閉じてほしくない画面

などです。

その場合は、cancel イベントでデフォルトの挙動を止めることができます。

dialog.addEventListener("cancel", (event) => {
  event.preventDefault();
});

ブラウザ標準の挙動を利用できるのは便利ですが、

標準ではどのように動くのか?

を理解したうえで、要件に合わせて調整する必要があります。


背景クリックで閉じる場合は、クリック範囲を判定する

今回のサンプルでは、モーダルのコンテンツ部分を .js-dialogModalWrap で囲っています。

<dialog class="js-dialogModal dialogModal" id="deleteModal">
  <div class="js-dialogModalWrap dialogModal__wrap">
    <!-- モーダルのコンテンツ -->
  </div>
</dialog>

JavaScriptでは、クリックされた場所が .js-dialogModalWrap の内側かどうかを判定します。

const dialogWrap = dialog.querySelector(".js-dialogModalWrap");

dialog.addEventListener("click", (event) => {
  if (dialogWrap.contains(event.target)) {
    return;
  }

  dialog.close();
});

モーダルのコンテンツ内をクリックした場合は何もせず、その外側をクリックした場合のみ close() を実行します。

例えば、入力フォームを操作したり、モーダル内のボタンを押したりしたときに意図せず閉じることを防ぐためです。

このように、モーダル本体とコンテンツ部分を分けておくことで、背景クリックによるクローズなどの処理も共通化しやすくなります。


show() と showModal() は違う

<dialog> には、

dialog.show();

と、

dialog.showModal();

があります。

似ていますが、動作は異なります。

show() は非モーダルなダイアログとして表示します。

一方、

dialog.showModal();

はモーダルダイアログとして表示します。

今回の記事で扱っているような、背景の操作を制限するモーダルの場合は showModal() を利用します。

初めて <dialog> を使う場合には、少し注意したいポイントです。


クラス化するとさらに使い回しやすい

ここまでのサンプルでは、ひとつのモーダルだけを対象にしています。

しかし実務では、

  • 削除確認
  • 検索フォーム
  • お知らせ
  • 編集フォーム

など、さまざまな場所でモーダルを使用します。

そのたびに、

const openButton = ...
const dialog = ...
const dialogWrap = ...
const cancelButton = ...

と個別に処理を書くのは大変です。

そこで、モーダルの処理をクラス化して共通化しておくと扱いやすくなります。

例えば、開くボタンには共通で、

class="js-buttonOpen"

を指定し、data-dialog に開きたいモーダルの id を設定します。

<button
  class="js-buttonOpen"
  data-dialog="deleteModal"
>
  削除する
</button>

別のモーダルであれば、

<button
  class="js-buttonOpen"
  data-dialog="searchModal"
>
  検索する
</button>

のように指定できます。

JavaScript側では、例えば次のように共通化できます。

class DialogModal {
  constructor() {
    this.openButtons = document.querySelectorAll(".js-buttonOpen");

    this.init();
  }

  init() {
    this.openButtons.forEach((button) => {
      button.addEventListener("click", () => {
        this.open(button);
      });
    });
  }

  open(button) {
    const dialogId = button.dataset.dialog;
    const dialog = document.getElementById(dialogId);

    if (!(dialog instanceof HTMLDialogElement)) {
      return;
    }

    const dialogWrap = dialog.querySelector(".js-dialogModalWrap");
    const cancelButton = dialog.querySelector(".js-cancel");

    dialog.showModal();

    cancelButton?.addEventListener(
      "click",
      () => {
        dialog.close();
      },
      { once: true }
    );

    dialog.addEventListener(
      "click",
      (event) => {
        if (dialogWrap?.contains(event.target)) {
          return;
        }

        dialog.close();
      },
      { once: true }
    );
  }
}

new DialogModal();

実際のプロジェクトでは、ここからさらに、

  • Escキーで閉じる・閉じないを設定する
  • 背景クリックで閉じる・閉じないを設定する
  • モーダルを開いたときに処理を実行する
  • 閉じたときに処理を実行する
  • モーダルごとのオプションを持たせる

といった機能を追加できます。

共通のクラスとして管理しておけば、各ページではHTML側で対象となるモーダルを指定するだけで利用できるようになります。

モーダルごとに個別のJavaScriptを書く必要が減るため、実装ルールの統一にもつながります。


自作モーダルと <dialog> を比較する

ここまでの内容を簡単に整理してみます。

項目

自作モーダル

<dialog>

デザインの自由度

高い

高い

独自の挙動

自由に実装しやすい

標準挙動を理解したうえで調整

重なり順

z-index などを自分で管理

Top Layer

背景

自分で実装

::backdrop が使える

Escキー

自分で実装

標準で対応

背景操作の制限

自分で実装

showModal() でブラウザが対応

開閉処理

プロジェクト側で決める

showModal() / close()

実装ルール

自由な分、ばらつく可能性がある

一定のルールに揃えやすい

特殊なUI

柔軟に対応しやすい

標準仕様に合わせる必要がある

どちらかが完全に優れているというより、それぞれ特徴があります。


自作モーダルのメリット・デメリット

メリット

自作モーダルの一番のメリットは、自由度の高さです。

HTML構造からCSS、JavaScriptまで自分たちで管理できるため、

  • 特殊なアニメーション
  • 独自の表示位置
  • 独自の閉じ方
  • 複雑なデザイン

などにも柔軟に対応できます。

また、z-index を含めた表示順についても、プロジェクト独自のルールで制御できます。

すでにプロジェクト内に自作モーダルの仕組みが確立されていて、ルールも統一されているのであれば、その仕組みを使い続ける選択肢もあります。

デメリット

一方で、自由度が高い分、自分たちで管理する範囲も増えます。

特に長期間運用しているプロジェクトでは、

z-index: 1000;
z-index: 9999;
z-index: 99999;

といった値がさまざまな場所に追加され、重なり順のルールが分かりにくくなることがあります。

また、モーダルごとに異なるJavaScriptを書くようになると、ファイルが増えたときに、

このモーダルはどの処理で動いているのか?

を追うことも難しくなります。

自由に作れるからこそ、プロジェクト側で明確なルールを作る必要があります。


<dialog> のメリット・デメリット

メリット

<dialog> のメリットは、モーダルとして必要になる基本的な仕組みをブラウザ標準の機能として利用できることです。

例えば、

  • Top Layerへの表示
  • ::backdrop
  • Escキーによるクローズ
  • 背景コンテンツの操作制限
  • showModal() / close() による開閉

などがあります。

自分たちで管理するものを減らせるため、実装をシンプルに保ちやすくなります。

また、共通クラスと組み合わせることで、複数の画面で同じルールを利用しやすいのもメリットです。

デメリット

一方で、ブラウザ標準の動作を理解したうえで実装する必要があります。

例えばEscキーで閉じる動作も、要件によっては止める必要があります。

また、

dialog.show();

と、

dialog.showModal();

の違いなど、<dialog> 固有の仕様について最初に理解しておく必要があります。

自作モーダルのようにすべてを自由に設計するというより、

ブラウザが用意している仕組みを利用し、その上に必要な機能を追加していく

という考え方になります。


モーダルを作るなら、まず <dialog> を検討してみる

自作モーダルと <dialog> を比較すると、どちらにもメリットがあります。

特殊なデザインや独自の挙動が多い場合には、自作モーダルの方が扱いやすいこともあります。

一方で、一般的なモーダルであれば、<dialog> を利用することで自分たちが管理する処理を減らすことができます。

個人的に実務で特に便利だと感じているのは、

ブラウザ標準の仕組みに寄せることで、プロジェクト内の実装ルールを統一しやすくなること

です。

自作の場合は自由に実装できる反面、人やページによって実装方法が変わる可能性があります。

<dialog> をベースにして、

dialog.showModal();

で開き、

dialog.close();

で閉じるという基本ルールを作っておけば、そこから共通クラスとして拡張できます。

また、.js-dialogModalWrap のように「モーダルのコンテンツ部分」を共通の構造として決めておけば、

  • コンテンツ外をクリックしたら閉じる
  • コンテンツ内のクリックは無視する

といった処理についても、モーダルごとに書き直す必要がなくなります。

特に z-index のような、プロジェクトが大きくなるほど管理が難しくなりやすい部分をブラウザ側に任せられるのは、実務では大きなメリットだと感じています。

新しくモーダルを実装するときには、最初から <div> で作り始めるのではなく、

このモーダルは <dialog> で実現できないか?

と一度検討してみるのもよいのではないでしょうか。


まとめ

今回は、自作モーダルと <dialog> を使ったモーダルを、できるだけ同じ構成で比較してみました。

自作モーダルは自由度が高く、特殊なUIや独自の動作にも対応しやすいメリットがあります。

一方で、プロジェクトが大きくなるにつれて、

  • z-index の管理
  • モーダルごとのJavaScript
  • 開閉方法の違い
  • 実装ルールのばらつき

などを自分たちで管理する必要があります。

<dialog> を利用すると、

  • Top Layer
  • ::backdrop
  • Escキーによるクローズ
  • 背景操作の制限
  • showModal() / close()

といったブラウザ標準の仕組みを利用できます。

もちろん、<dialog> を使えば何も考えなくてよいわけではありません。

Escキーで閉じてほしくない場合の対応や、背景クリック時の判定、show() と showModal() の違いなど、仕様を理解したうえで実装する必要があります。

それでも、自前ですべての仕組みを作るのではなく、ブラウザがすでに持っている機能を利用し、その上でプロジェクトに必要な処理を追加していくことで、コードや実装ルールをシンプルに保ちやすくなります。

モーダルを新しく実装する機会があれば、選択肢のひとつとして <dialog> を試してみてはいかがでしょうか。