はじめに
モーダルやドロワーを実装して、表示中に背景ページがスクロールしないよう対策を入れた。PCのChromeでは問題なく動く。ところがiPhoneのSafariで開くと、今度はモーダルの中がスクロールできない。指で動かそうとしても反応しないか、動いても指に張り付いたようにガクガクして、指を離した瞬間にピタッと止まる(慣性スクロールが効かない)。
利用規約モーダルのように「最後までスクロールしないと同意ボタンに届かない」設計だと、ユーザーは先に進めなくなります。この症状の多くは、背景スクロール防止のために入れた touchmove の preventDefault が原因です。この記事では、なぜモーダル内のスクロールまで巻き添えになるのかと、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をやめて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でモーダル内のスクロールが効かない・ガクガクする症状の多くは、背景スクロール防止のために入れた touchmove の preventDefault がモーダル内のタッチスクロールまで巻き添えにしていることが原因です。e.target の条件分岐で逃がすパッチ対応はスクロール連鎖やネストで破綻しやすいため、タッチイベントの制御自体をやめるのが確実です。
修正はモーダル表示中だけbodyを position: fixed で固定する方式に置き換えます。モーダル内はネイティブスクロールのまま慣性も効き、背景も完全に止まります。閉じるときのスクロール位置復元だけ忘れないようにしてください。この挙動はPCのブラウザやシミュレーターでは再現しないため、モーダルやドロワーを実装したら実機のiPhoneでスクロールの感触まで確認する習慣にしておくと安心です。
関連するUI事例
この問題が起きやすい代表的なUI事例です。いずれもコピペで動くサンプルを掲載しています。
同じiOS Safariシリーズの記事もあわせてどうぞ。