iOS Safariでモーダル内のスクロールが効かない・ガクガクする原因と対策

はじめに

モーダルやドロワーを実装して、表示中に背景ページがスクロールしないよう対策を入れた。PCのChromeでは問題なく動く。ところがiPhoneのSafariで開くと、今度はモーダルの中がスクロールできない。指で動かそうとしても反応しないか、動いても指に張り付いたようにガクガクして、指を離した瞬間にピタッと止まる(慣性スクロールが効かない)。

利用規約モーダルのように「最後までスクロールしないと同意ボタンに届かない」設計だと、ユーザーは先に進めなくなります。この症状の多くは、背景スクロール防止のために入れた touchmovepreventDefault が原因です。この記事では、なぜモーダル内のスクロールまで巻き添えになるのかと、touchmove に頼らない修正方法をまとめます。

何が起きているか

iPhoneでのページスクロールも、モーダル内の overflow-y: auto な領域のスクロールも、どちらも指のタッチ操作から始まります。背景を止めるつもりで document に次のようなコードを入れると、タッチ由来のスクロールがページ内で全部止まります。

// 背景スクロール防止のよくあるNG実装
document.addEventListener('touchmove', (e) => {
  e.preventDefault(); // モーダル内のスクロールも巻き添えで止まる
}, { passive: false });

preventDefault は「このタッチ操作に紐づくブラウザの既定動作(スクロール)をキャンセルする」という意味で、発生源がモーダルの中か外かを区別しません。背景だけを狙ったつもりでも、モーダル内の慣性スクロールごとキャンセルされます。

なお、モバイルブラウザでは document レベルのタッチイベントリスナーは既定でpassive(preventDefault 無効)として扱われるため、この実装には { passive: false } の指定が必要です。裏を返すと、これを付けた時点で「ページ内のあらゆるタッチスクロールを止められる状態」を自分で作っていることになります。また、touchmove の中で条件判定や重い処理を挟むと、ブラウザはその処理の完了を待ってからスクロールを描画するため、動く場合でもガクガクした引っかかりが出ます。

「モーダル内だけ許可する」条件分岐が破綻しやすい理由

この問題に気づいたとき、最初に思いつくのは e.target を判定して、モーダル内から始まったタッチだけ preventDefault を免除する方法です。

// 一見直りそうだが、エッジケースの多いパッチ対応
document.addEventListener('touchmove', (e) => {
  if (e.target.closest('.modal-body')) return; // モーダル内は許可
  e.preventDefault();
}, { passive: false });

これで一応スクロールは動きますが、別の問題が残ります。モーダル内のスクロールが上端や下端に達した状態でさらにスワイプすると、スクロールが親(=背景ページ)に連鎖して背景が動き出します。モーダル内にセレクトボックスや別のスクロール領域がネストしている場合の判定も漏れがちです。touchmove をJSで制御して「どこをスクロールさせるか」を自前で采配する設計は、エッジケースを潰し続けるいたちごっこになりやすいので、発想ごと変えるのが確実です。

この挙動は記事内では再現できません

タッチ操作による慣性スクロールの挙動は、記事内の小さい枠やPCのブラウザでは再現できません。お手元のiPhoneで以下のリンクを開いて確認してください。

崩れる例 touchmoveをpreventDefault 利用規約モーダルの中がスクロールできず、同意ボタンまで到達できない状態を確認できます。 修正例 body固定方式 モーダル内は慣性の効いた自然なスクロールのまま、背景も動かない修正版を確認できます。

崩れるパターンでは、モーダルを開いて規約の本文を指でスクロールしてみてください。動かないか、動いても慣性が効かず指に張り付く感触になるはずです。

修正方法:touchmoveをやめてbodyを固定する

「背景を止めるためにタッチイベントを制御する」のをやめて、「モーダル表示中は背景(body)をスクロールできない状態にしておく」設計に切り替えます。モーダルを開くときにbodyを position: fixed で固定する方式です。

let savedScrollY = 0;

function openModal() {
  savedScrollY = window.scrollY;
  document.body.style.position = 'fixed';
  document.body.style.top = `-${savedScrollY}px`; // 見た目の位置を保つ
  document.body.style.left = '0';
  document.body.style.right = '0';
  overlay.hidden = false;
}

function closeModal() {
  overlay.hidden = true;
  document.body.style.position = '';
  document.body.style.top = '';
  document.body.style.left = '';
  document.body.style.right = '';
  window.scrollTo(0, savedScrollY); // 開く前の位置に戻す
}

bodyが position: fixed になると背景ページはスクロール可能な高さを失うため、どうスワイプされても動きません。一方でモーダル内の overflow-y: auto 領域には何も手を加えていないので、ネイティブスクロールのまま慣性も効きます。touchmove のリスナー自体が不要になるため、ガクつきの原因も消えます。

2点だけ注意があります。bodyを固定すると背景ページはスクロール位置を失って先頭に巻き戻るため、top: -スクロール位置 で見た目を保ち、閉じるときに window.scrollTo で復元する処理をセットで入れてください。ここを省くと「モーダルを閉じたらページの先頭に飛ばされる」という別の不具合になります。もうひとつ、この方式は背景スクロール防止の定番でもあります。背景側の症状(overflow: hiddenがキーボード表示中やズーム中に効かなくなる話)から見た解説はiPhoneでモーダル表示中に背景がスクロールする原因と対策にまとめています。

補足:overscroll-behaviorによるスクロール連鎖の防止

モーダル内のスクロールが端に達したとき、親へスクロールが連鎖する挙動だけであれば、CSSの overscroll-behavior で止められます。

.modal-body {
  overflow-y: auto;
  overscroll-behavior: contain; /* スクロール連鎖を内側で止める */
}

iOS Safariでは16以降でサポートされています。ただしこれは「スクロール中の連鎖」を止めるプロパティで、暗い背景(バックドロップ)の上を直接スワイプされた場合には効きません。単独で完全なロックにはならないため、body固定方式の保険として併用する位置づけで考えてください。

まとめ

iPhoneでモーダル内のスクロールが効かない・ガクガクする症状の多くは、背景スクロール防止のために入れた touchmovepreventDefault がモーダル内のタッチスクロールまで巻き添えにしていることが原因です。e.target の条件分岐で逃がすパッチ対応はスクロール連鎖やネストで破綻しやすいため、タッチイベントの制御自体をやめるのが確実です。

修正はモーダル表示中だけbodyを position: fixed で固定する方式に置き換えます。モーダル内はネイティブスクロールのまま慣性も効き、背景も完全に止まります。閉じるときのスクロール位置復元だけ忘れないようにしてください。この挙動はPCのブラウザやシミュレーターでは再現しないため、モーダルやドロワーを実装したら実機のiPhoneでスクロールの感触まで確認する習慣にしておくと安心です。

関連するUI事例

この問題が起きやすい代表的なUI事例です。いずれもコピペで動くサンプルを掲載しています。

同じiOS Safariシリーズの記事もあわせてどうぞ。