overscroll-behavior を使ってダイアログを開いている時はページのスクロールをしないようにする

まず以下のダイアログを考える。このダイアログはダイアログ内のコンテンツが一定の高さに到達するとスクロールバーが出るようになっている。

<div class="content">
  <button class="open-button" type="button">open modal</button>

  <p>メッセージ1</p>
  <p>メッセージ2</p>
  ...

  <div class="modal">
    <div class="modal-content">
      <div class="modal-header">
        <h2>Modal Header</h2>
      </div>
      <div class="modal-body">
        <p>メッセージ1</p>
        <p>メッセージ2</p>
        ...
      </div>
      <div class="modal-footer">
        <button>OK</button>
        <button>Cancel</button>
      </div>
    </div>
  </div>
</div>
* {
  box-sizing: border-box;
}

.modal {
  display: block;
  position: fixed;
  z-index: -1;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  opacity: 0;
}
.modal.open {
  z-index: 1000;
  opacity: 1;
}
.modal::after {
  display: block;
  position: absolute;
  z-index: 1;
  content: ' ';
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background-color: rgba(0, 0, 0, 0.4);
}
.modal-content {
  display: flex;
  flex-direction: column;
  position: absolute;
  z-index: 2;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 80%;
  border: 1px solid #888;
  background-color: #fff;
}
.modal-header {
  width: 100%;
}
.modal-body {
  width: 100%;
  height: 200px;
  overflow-y: auto;
}
.modal-footer {
  width: 100%;
  margin-top: auto;
}

しかし、ダイアログの中でスクロールしているときにダイアログのスクロールバーが端に到達するとページのスクロールバーが動いてしまう。また、ホイールしたときにポインターがダイアログ外にあるときもページのスクロールバーが動いてしまう。

この動作を防ぐためには、overscroll-behavior プロパティを使って、デフォルトの動作を変える必要がある。overscroll-behavior にはいくつか指定できるが、none を指定することで、親要素のスクロールバーにスクロール操作が伝搬することを無効にすることができる。

しかし、overscroll-behavior プロパティは以下の状態でしか指定が有効にならない。

  1. 要素のスクロールバーが表示されている
  2. 要素のスクロールバーのバーが表示されている

1. については要素に overflow-y: scroll を指定することで解決できる。しかし、これが指定されただけだと要素のコンテンツが要素内に収まる場合はスクロールバーのバーが表示されないため 2. の条件が有効にならない。

これを解決するためには、要素のコンテンツ、つまり子要素の高さの合計が、100% を超える必要がある。これを実現する手っ取り早い方法が height: calc(100% + 1px); で、常に親要素より 1px だけ高くなるので、スクロールバーのバーが表示されるようになる。

これを踏まえて上記のダイアログを改良する。方針としてはダイアログのレイヤー全体.modaloverscroll-behavior: none; を指定し、その子要素、ここではダイアログの背景.modal::afterheight: calc(100% + 1px); を指定する。また、ダイアログの本文.modal-bodyには overscroll-behavior: contain; を指定するとスマホで端に到達したときにスクロールバーがバウンスするデフォルトの動作をするようになるので、こちらも指定する。

...
.modal {
  display: block;
  position: fixed;
  z-index: -1;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
+  overflow-y: scroll;
+  overscroll-behavior: none;
  opacity: 0;
}
.modal::after {
  display: block;
  position: absolute;
  z-index: 1;
  content: ' ';
  top: 0;
  left: 0;
  right: 0;
-  bottom: 0;
+  height: calc(100% + 1px);
  background-color: rgba(0, 0, 0, 0.4);
}
...
.modal-body {
  width: 100%;
  height: 200px;
  padding: 15px;
  overflow-y: auto;
+  overscroll-behavior: contain;
}
...

これで、ダイアログのスクロールバーが端に到達してもページのスクロールバーが動かなくなった。ここでこの方法のデメリットとして以下がある。

  1. ダイアログレイヤー.modalにスクロールバーが常に表示される
  2. ダイアログレイヤー.modalが1pxスクロールできてしまうので、場合によっては微妙にカクつく

この動作が気になる場合はこの方法を使うことはできないが、基本的に問題ないはず。

また、overscroll-behavior は以下の環境以外では使用できないが、この場合でも親要素のスクロールバーが動くだけなので致命的な問題ではないはず。

なお、この方針で実装したサンプルが以下にある。

参考