iPhoneでモーダル表示中に背景がスクロールする原因と対策

はじめに

モーダルやドロワーを開いたとき、後ろのページが動かないように body { overflow: hidden } を入れた。PCのChromeでもFirefoxでも背景はきちんと止まる。ところがiPhoneのSafariでは、モーダル内の入力欄をタップしてキーボードが出た状態や、ピンチズームで拡大した状態でスワイプすると、背景のページがするすると動いてしまう。モーダルを閉じたら、開く前とは全然違う場所に飛ばされていたというやつです。

「iOSはbodyのoverflow: hiddenを無視する」という昔からの定番知識で検索すると古い情報が多く出てきますが、現在のiOSでは事情が変わっています。この記事では、今のiOS Safariで背景がスクロールする正確な条件と、条件を問わず確実に背景を止める方法をまとめます。

何が起きているか

デスクトップブラウザでは、body(またはルート要素)に overflow: hidden を指定すればページのスクロールバーが消え、背景は完全に止まります。モーダル表示中の背景固定として最もよく知られた方法です。

/* PCでは効くが、iOS Safariでは背景が止まらない */
body.modal-open {
  overflow: hidden;
}

かつてのiOS Safariは、タッチ操作によるページスクロールを bodyoverflow 指定だけでは止めませんでした。これが「iOSではoverflow: hiddenが効かない」という定番知識の出どころですが、この基本ケースはWebKit側で修正済みで、現在のiOS・iPadOSでは通常状態でモーダルを開けば背景は止まります。

問題は、直っていない穴が残っていることです。visual viewport(実際に見えている範囲)がlayout viewportより小さい状態、具体的にはピンチズームで拡大しているときと、入力欄をタップして仮想キーボードが表示されているときは、overflow: hidden を指定していても背景がスクロールします。これはWebKitのバグトラッカーに未解決のまま登録されている挙動で、ズーム中に画面が固まって身動きできなくなるのを避けるための意図的な設計という側面もあり、すぐに直る見込みはありません。

つまり今この問題を踏むのは、利用規約への同意やログイン、検索のように「モーダルの中に入力欄があってキーボードが出る」画面です。加えて、サポート対象に古いiOSが含まれる場合や、ホーム画面に追加したPWAとして動かす場合にも既知の穴が残っています。

touchmoveをpreventDefaultで止めてはいけない理由

「CSSで止まらないならJSで止める」と考えて、touchmove イベントを preventDefault でキャンセルする方法が昔からよく紹介されています。

// 背景は止まるが、モーダル内のスクロールまで死ぬNG実装
document.addEventListener('touchmove', (e) => {
  e.preventDefault();
}, { passive: false });

これで背景は確かに止まりますが、タッチ由来のスクロールを発生源ごと全部キャンセルするため、モーダル内のスクロールまで動かなくなります。利用規約モーダルのようにスクロール前提のコンテンツを入れていると、ユーザーが先に進めなくなる別の不具合に化けます。この症状の詳細と、条件分岐で逃がすパッチ対応が破綻しやすい理由はiOS Safariでモーダル内のスクロールが効かない・ガクガクする原因と対策にまとめています。

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

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

崩れる例 overflow: hiddenのみ キーボード表示中に背景が動く状態を、スクロール位置の数字付きで確認できます。 修正例 body固定方式 キーボード表示中でも背景のスクロール位置が動かず、閉じると元の位置に戻る修正版を確認できます。

崩れるパターンでは、モーダルを開いて規約末尾の氏名入力欄をタップし、キーボードが出た状態でページをスワイプしてみてください。モーダル上部に表示している「背景の位置」の数字が動くはずです。ピンチズームで少し拡大した状態でも再現します。逆に、キーボードを出さない通常状態では背景が止まること(基本ケースは修正済みであること)もあわせて確認できます。

修正方法:bodyをposition: fixedで固定する

overflow: hidden の代わりに、モーダル表示中だけ bodyposition: fixed で固定します。bodyが固定されるとページはスクロールできる高さを失うため、キーボード表示中かどうかやズーム状態に関係なく、背景のスクロール位置は動かなくなります。

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 にした瞬間、ページはスクロール位置を失って先頭に巻き戻ります。そこで固定する直前の window.scrollY を控えておき、top にマイナス値で指定して見た目の位置を保ちます。閉じるときはスタイルを戻したうえで window.scrollTo で元の位置に復元します。この復元を忘れると「モーダルを閉じるたびにページの先頭に飛ばされる」という別の不具合になるので、開くと閉じるは必ずセットで実装してください。

モーダル内のスクロール領域(overflow-y: auto の要素)には何も手を加えないため、慣性の効いたネイティブスクロールはそのまま使えます。

補足:overscroll-behaviorとの併用

モーダル内のスクロールが端に達したときの連鎖だけを狙って止めるCSSプロパティもあります。

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

iOS Safariでは16以降でサポートされています。ただしバックドロップの上を直接スワイプされた場合には効かないため、これ単独では背景固定になりません。主役はbody固定方式にして、overscroll-behavior はスクロール連鎖を確実に断つ保険として併用する使い方をおすすめします。

まとめ

body { overflow: hidden } はiOS Safariで効かない」というのは過去の話になりつつあり、現在のiOS・iPadOSでは通常状態なら背景は止まります。ただしピンチズーム中と仮想キーボード表示中は今も止まらず、WebKit側でも未解決のまま残っています。入力欄のあるモーダルほど踏みやすい穴です。

対策はモーダル表示中だけbodyを position: fixed で固定する方式が確実です。touchmovepreventDefault で止める方法はモーダル内のスクロールまで巻き添えにするため使いません。固定時の top: -スクロール位置 と、閉じるときの復元をセットで実装するのだけ忘れないようにしてください。PCのブラウザやキーボードを出さない操作では止まって見えるので、入力欄のあるモーダルを実装したら、キーボードを出した状態の挙動まで実機のiPhoneで確認しておくと安心です。

関連するUI事例

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

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