dialogの挙動を制する 前編 dialog要素の「できない」を解決する

dialog要素は強力で便利ですが、「これはできない」と思われがちな機能もあります。前編では、Escキーで閉じるのを防ぐ、backdropクリックで閉じる、閉じるときのアニメーション、Reactでのpropsによる開閉制御——よくある4つの課題の解決策を紹介します。

発行

著者 小山田 晃浩 フロントエンド・エンジニア
dialogの挙動を制する シリーズの記事一覧

はじめに

dialog要素によるモーダルダイアログは、ブラウザネイティブのTop Layerや、自身以外の要素をinertにする仕組みをもつ強力な要素です。

Top Layerは、z-indexに関係なく最前面に表示できる機能です。inertは、指定した範囲のクリック、フォーカス、選択、入力などをすべて無効にし、「描画はされても存在しない」状態にする機能で、モーダルが開いているときにモーダル以外の要素をユーザーから操作できないようにします。

これらの機能のおかげで、従来はJavaScriptとCSSを駆使しなければならなかったモーダルダイアログの実装が、ずっと手軽になりました。

補足:Top Layer

Top Layerとは、z-indexの重なりに関係なく必ず最前面に表示できる機能です。詳しくは、「ポップオーバーAPIを攻略する 第2回 ポップオーバーの表示の特徴」で解説しています。

補足:inert

inertについての詳細は、「非活性状態を制御するinert属性」シリーズで解説しています。

ただ、便利な一方で「これはできないのでは?」と思われがちな挙動もあります。この記事ではまず、よくある次の4つの課題とその解決策を紹介します。

  • Escキーで閉じるのを防ぐ
  • backdropをクリック時にモーダルを閉じる
  • 閉じるときのアニメーション
  • Reactでのprops渡しで開閉を制御する

なお、比較的新しい機能を使った解決策には、まだブラウザサポートが揃っていないものもあります。そのためフォールバックの実装も併せて紹介します。

デモリポジトリ

本文中で紹介しているデモは次のリポジトリにまとまっています。併せて、ご参照ください。

Escキーで閉じるのを防ぐ

dialog要素をモーダル状態で開くと、Escキーを押下したときにダイアログが閉じます。一方で、ユーザーが確実に決定ボタンを押すまでは閉じさせたくない場面もあります。その場合は、Escキー押下で閉じると困ってしまいます。

この場面ではclosedby属性が役に立ちます。closedby="none"を指定すると、Escキーでの閉じる動作を無効にできます。

ただし、closedby属性はまだ新しい仕様で、サポートされていないブラウザでは効果がありません。そのため、フォールバックとしてkeydownイベントでEscキーを検知し、event.preventDefault()する方法を併せて指定しておくと、closedbyがサポートされていないブラウザでもEscキー押下で閉じなくなります。

Escキーで閉じるのを防ぐ

<dialog
  id="modal1"
  closedby="none"
  onkeydown="event.key === 'Escape' && event.preventDefault()"
>
  modal contents<br />
  esc key close is prevented<br />
  <button onclick="modal1.close()">
    close
  </button>
</dialog>

補足:closedby属性のブラウザサポート

closedby属性は2025年ごろから仕様策定が進んでいます。執筆時点ではChromeとFirefoxでサポートされていますが、Safariではサポートされていません。詳しくは Can I use を参照してください。

backdropクリックで閉じる

モーダルが開いているとき、::backdrop擬似要素が背後に表示されます。この領域をクリックしてダイアログを閉じたい、という要望はよくあります。ただ、::backdropは擬似要素なので、直接イベントハンドラを設定できません。

新しいclosedby属性を使えば、closedby="any"を指定することで、backdropクリックでもモーダルを閉じる機能(light dismiss)を有効にできます。

一方で、前述のとおり、closedby属性はまだサポートが揃っていないため、Safariなどのブラウザではこの方法が効きません。

Safari向けのフォールバックとしては、dialog要素のonclickイベントを使う方法があります。::backdropは擬似要素であるため、クリックしても擬似要素自体はイベントのターゲットにならず、代わりにdialog要素がイベントのターゲットになります。つまりdialog要素がクリックされたときに閉じるようにすれば、backdropクリックを検知できます。

ただし、このままでは、ダイアログのどこをクリックした場合でも「閉じるためのクリック」として検知してしまいます。ダイアログのコンテンツ部分のクリックは除外したいので、dialogの直下に<div>などのラッパー要素を配置し、クリックの発生元がdialog要素自体か、dialogの子孫要素かを判定するようにします。

つまり、このようなDOM構造にします。

dialogとbackdropのDOM構造

<dialog>
  ::backdrop
  <div>
    コンテンツ
  </div>
</dialog>

そして、

  • クリックの発生元がdialogの子孫要素の場合は、なにもしない。つまり、event.target !== event.currentTarget
  • クリックの発生元が、dialog要素自体だった場合、閉じる。つまり、event.target === event.currentTarget

として、ダイアログ内のコンテンツをクリックしたときはevent.targetが内側の<div>や子孫の要素になるため、event.target === event.currentTargetの条件が不成立になり、意図せず閉じるのを防げます。

このとき重要なのが、ダイアログのコンテンツを内側の<div>で囲む点です。この構造がなければ、ダイアログ内のクリックとbackdropのクリックを区別できません。

backdropクリックで閉じる

<dialog
  id="modal2"
  closedby="any"
  onclick="event.target === event.currentTarget && event.currentTarget.close()"
>
  <div>
    <!-- inner div is required to stopPropagation -->
    modal contents<br />
    light dismiss-ish<br />
    <button onclick="modal2.close()">
      close
    </button>
  </div>
</dialog>

閉じるときのアニメーション

ダイアログを開くときのアニメーションはCSS Animationsで実現できます。しかし閉じるときはdisplay: noneになると同時にアニメーションが消えてしまうため、単純なやり方では実現できません。

この問題を解決するのがtransition-behavior: allow-discreteです。

通常は、display: noneは要素が非表示なので、アニメーションの初期の状態が計算できず、アニメーションができません。ですが、transition-behavior: allow-discreteを指定すると、display: noneを含む変化もCSS Transitionsで扱えるようになります。併せて@starting-styleでトランジション開始前のスタイルを定義することで、display: none直前の状態を指定できます。

また、transition-propertyoverlayを指定すると、dialogが閉じてTop Layerから外れるタイミングをアニメーション完了まで遅らせることができます。overlayはブラウザ内部専用のプロパティで、値を直接変更することはできませんが、transition-propertyに含めることで機能します。

transition-behavior: allow-discreteを使った開閉アニメーション

dialog {
  transition-property: opacity, transform, display, overlay;
  transition-duration: .5s;
  transition-behavior: allow-discrete;

  @starting-style {
    opacity: 0;
    transform: scale(0.9);
  }
}

dialog:not([open]) {
  opacity: 0;
  transform: scale( .9 );
}

補足:transition-behavior: allow-discreteのブラウザサポート

transition-behavior: allow-discreteはChrome 117・Firefox 129・Safari 17.4以降でサポートされています。少し古いSafariをサポート対象にしている場合は注意が必要です。詳しくは Can I use を参照してください。

transition-behavior: allow-discrete未サポートの環境でもアニメーションを活かしたい場合には、@supportsで分岐させます。サポートされていないブラウザでは、開くときだけCSS Animationを使い、閉じるときのアニメーションは諦めるのが現実的な方針です。開くアニメーションだけでも、意外とユーザーには十分な印象を与えられます。

@supportsを使ったブラウザ対応

@keyframes dialog-in {
  0% {
    opacity: 0;
    transform: scale(0.9);
  }
  100% {
    opacity: 1;
    transform: none;
  }
}

/* allow-discrete が効かないブラウザ用に in は animation で対応 */
dialog {
  animation: dialog-in .5s 1;
}

@supports (transition-behavior: allow-discrete) {
  dialog {
    /* allow-discrete が効くブラウザには transition で対応し、animation は無効にする */
    animation: none;
    transition-property: opacity, transform, display, overlay;
    transition-duration: .5s;
    transition-behavior: allow-discrete;

    @starting-style {
      opacity: 0;
      transform: scale(0.9);
    }
  }

  dialog:not([open]) {
    opacity: 0;
    transform: scale( .9 );
  }
}

Reactでの利用時にprops渡しで開閉

dialog要素をモーダルとして開くにはshowModal()メソッドを呼び出す必要があります。これはDOMメソッドであるため、Reactの宣言的なデータフローとは相性がよくありません。

Reactでダイアログをラップしたコンポーネントを作る場合、ブーリアン型でopenのようなpropsを受け取り、その値に応じて開く・閉じるを制御するのが定石です。useRefでダイアログ要素への参照を保持し、useEffectでpropsの変化を監視してdialog要素のshowModal()close()メソッドを呼び出すといいでしょう。

Reactでdialogを開閉するコンポーネント

import { useEffect, useRef } from "react";

function Dialog({ open, onClose, children }) {
  const ref = useRef(null);

  useEffect(() => {
    const dialog = ref.current;

    if (open && !dialog.open) {
      dialog.showModal();
    }

    if (!open && dialog.open) {
      dialog.close();
    }
  }, [open]);

  return (
    <dialog ref={ref} onClose={onClose}>
      {children}
    </dialog>
  );
}

すでに開いている状態でshowModal()を呼び出すとエラーになるため、dialog.openを確認してからshowModal()close()を呼び出すようにするといいでしょう。

また、ユーザーがEscキーなどでダイアログを閉じた場合、Reactのopen propと実際のDOMの状態(dialog.open)が食い違ってしまうことがあります。これを防ぐために、onCloseには親コンポーネント側でopenfalseにできるコールバックを渡しておきましょう。

ここまでのまとめ

dialog要素のよくある課題とその解決策を紹介しました。

  • Escキーで閉じるのを防ぐclosedby="none"keydown + preventDefault()の組み合わせ
  • backdropクリックで閉じるclosedby="any"onclickでのevent.target比較
  • 閉じるときのアニメーションtransition-behavior: allow-discrete@supportsによるフォールバック
  • Reactでのprops渡し開閉useRef + useEffectでDOMメソッドをブリッジ

次回は、Top Layerやinertの強力さゆえに起きる想定外の問題と、その対処法を紹介します。