【JavaScript】モーダル表示中に背景のスクロールを禁止する方法|overflow:hidden・スクロール位置保持・iOSバウンス対策・スクロールバーずれ防止

【JavaScript】モーダル表示中に背景のスクロールを禁止する方法|overflow:hidden・スクロール位置保持・iOSバウンス対策・スクロールバーずれ防止 JavaScript

モーダルウィンドウやスライド式のメニュー(ドロワー)を開いたとき、背景のページがスクロールできてしまうと、操作感がちぐはぐになります。モーダル内をスクロールしているつもりが裏のページが動いたり、閉じたときに表示位置が変わっていたりして、利用者を戸惑わせます。

そこで、オーバーレイ表示中は背景のスクロールを止める「スクロールロック」を実装します。一見するとbodyoverflow: hiddenを付けるだけに見えますが、実際にはスクロールバー消失によるガタつきiOS Safariで効かない問題スクロール位置のずれという3つの落とし穴があります。

この記事では、基本の方法から各問題への対処、そして複数のモーダルにも耐える再利用可能な実装までを順に組み立てます。

先に結論

  • PCだけならdocument.body.style.overflow = "hidden"で十分ですが、スクロールバーが消えて画面が右にずれます。
  • ずれは、消えるスクロールバーの幅をpadding-rightで補うか、CSSのscrollbar-gutter: stableで防ぎます。
  • iOS Safariはoverflow: hiddenを無視するため、position: fixed方式が必要です。
  • position: fixed方式では、現在のスクロール位置を保存し、閉じるときに復元します。
  • モーダル内のスクロールを背景へ伝えないためにoverscroll-behavior: containを併用します。
  • オーバーレイが複数重なる場合は、参照カウントで「最後の1つが閉じたとき」だけ解除します。

モーダル本体の実装はMicromodal.jsでモーダルを実装する方法、オーバーレイメニューはハンバーガーメニューの実装方法で解説しています。本記事はそれらと組み合わせる「背景ロック」に絞って掘り下げます。

スポンサーリンク

最も簡単な方法:bodyにoverflow:hidden

基本は、モーダルを開くときにbodyoverflow: hiddenを付け、閉じるときに外すだけです。スクロールできる領域がなくなるため、背景は動かなくなります。

scroll-lock-basic.js
function openModal(modal) {
  modal.hidden = false;
  document.body.style.overflow = "hidden"; // 背景を固定
}

function closeModal(modal) {
  modal.hidden = true;
  document.body.style.overflow = ""; // 元に戻す
}

PCのマウス操作だけを考えるなら、これでほぼ目的を達成できます。ただし、ここから2つの問題が出てきます。1つは見た目のガタつき、もう1つはスマートフォン(特にiOS)で効かないことです。順に対処します。

問題1:スクロールバーが消えてガクッと右にずれる

PCのブラウザでは、ページに縦スクロールバーが表示されています。overflow: hiddenでスクロールをなくすと、このスクロールバーの幅(おおよそ15〜17px)が消え、その分だけページ全体が右へ広がります。モーダルを開いた瞬間に背景が「ガクッ」と動いて見えるのはこのためです。

対策は、消えるスクロールバーの幅をpadding-rightで補い、幅が変わらないようにすることです。スクロールバーの幅は次の式で求められます。

scroll-lock-padding.js
function lockScroll() {
  // 消えるスクロールバーの幅を測る
  const scrollbarWidth =
    window.innerWidth - document.documentElement.clientWidth;

  document.body.style.overflow = "hidden";

  if (scrollbarWidth > 0) {
    document.body.style.paddingRight = `${scrollbarWidth}px`;
  }
}

function unlockScroll() {
  document.body.style.overflow = "";
  document.body.style.paddingRight = "";
}

window.innerWidthはスクロールバーを含む幅、document.documentElement.clientWidthはスクロールバーを除いた幅です。その差が、いま表示されているスクロールバーの幅になります。スクロールバーが元々ない環境(多くのスマホやスクロールバーを自動的に隠すOS)では差が0になるため、ifで余計な余白を付けないようにしています。

CSSだけで予防する scrollbar-gutter

モダンブラウザでは、CSSのscrollbar-gutter: stablehtmlに指定しておくと、スクロールバーの有無にかかわらず常にその幅の領域が確保されます。これだけで、スクロールロック時のガタつきを根本から防げます。padding-rightの計算が不要になるため、対応環境が許すなら第一候補です。

scrollbar-gutter.css
html {
  scrollbar-gutter: stable;
}

問題2:iOSのSafariでは背景スクロールが止まらない

より厄介なのがこちらです。iOSのSafariでは、bodyoverflow: hiddenを付けても、指でのスワイプによる背景スクロールが止まりません。モーダルを開いたまま背景を触ると、裏のページが普通に動いてしまいます。これはiOS Safariの長年の挙動で、overflow: hiddenだけでは解決しません。iOS Safariはビューポート周りに癖が多く、要素の高さが意図通りにならない100vhが効かない問題も同じ系統の注意点です。

overflow:hiddenだけではスマホで不十分

PCのブラウザで動作確認すると問題なく見えるため、見落とされがちです。スクロールロックは必ず実機のiOS Safariでも確認してください。Androidの一部ブラウザでも、背景のバウンス(端での跳ね返り)が残ることがあります。

iOSにも効く position:fixed 方式(スクロール位置も保持)

iOSでも確実に背景を止めるには、bodyposition: fixedにして、ページそのものを画面に固定します。ただしfixedにすると表示が一番上へ戻ってしまうため、現在のスクロール位置を保存し、見た目が変わらないようにtopでずらすのがポイントです。閉じるときは、保存した位置へスクロールを戻します。

scroll-lock-fixed.js
let savedScrollY = 0;

function lockScroll() {
  savedScrollY = window.scrollY;

  const scrollbarWidth =
    window.innerWidth - document.documentElement.clientWidth;

  // ページを画面に固定し、見た目の位置を維持する
  document.body.style.position = "fixed";
  document.body.style.top = `-${savedScrollY}px`;
  document.body.style.left = "0";
  document.body.style.right = "0";

  if (scrollbarWidth > 0) {
    document.body.style.paddingRight = `${scrollbarWidth}px`;
  }
}

function unlockScroll() {
  document.body.style.position = "";
  document.body.style.top = "";
  document.body.style.left = "";
  document.body.style.right = "";
  document.body.style.paddingRight = "";

  // 保存した位置へ戻す
  window.scrollTo(0, savedScrollY);
}

position: fixedbodyを固定すると、要素が通常の配置から外れて幅が縮むことがあります。これを防ぐためleft: 0right: 0で左右を画面端に固定し、幅いっぱいに広げます。width: 100%を使わないのは、padding-rightでスクロールバー幅を補ったときに、width: 100%だと横方向へはみ出して水平スクロールバーが出てしまうためです。left: 0; right: 0;ならpadding-rightは内側に収まり、はみ出しません。top: -savedScrollYで、固定する前に見えていた位置をそのまま表示し続けます。閉じたあとにwindow.scrollTo()で元の位置へ戻せば、利用者は移動に気づきません。スクロール位置の指定方法は特定の位置までスクロールさせる方法も参考になります。

モーダル内のスクロールを背景に伝えない overscroll-behavior

内容が長いモーダルでは、モーダル自体をスクロールさせます。このとき、モーダルの端まで到達したスクロールが背景へ「連鎖」して、裏のページが動くことがあります。これを止めるのがCSSのoverscroll-behavior: containです。

overscroll-behavior.css
.modal-body {
  overflow-y: auto;
  overscroll-behavior: contain; /* 背景へのスクロール連鎖を防ぐ */
}

overscroll-behavior: containは、その要素の中でスクロールが完結するようにし、端を越えたスクロールが親要素や背景へ波及するのを防ぎます。position: fixed方式と組み合わせると、モーダル内は普通にスクロールでき、背景は完全に静止する、という理想的な状態に近づきます。

複数のモーダルに対応する(参照カウント)

モーダルの上にさらに確認ダイアログを重ねるような画面では、単純なlockunlockだと問題が起きます。内側のダイアログを閉じた時点でスクロールロックが解除され、まだ開いている外側のモーダルの背景が動いてしまうのです。

対策は、ロックの数を数える参照カウントです。ロックが0から1になったときだけ固定を適用し、1から0になったときだけ解除します。

scroll-lock-counter.js
let lockCount = 0;
let savedScrollY = 0;

export function lockScroll() {
  // すでにロック中なら数を増やすだけ
  if (lockCount === 0) {
    savedScrollY = window.scrollY;

    const scrollbarWidth =
      window.innerWidth - document.documentElement.clientWidth;

    document.body.style.position = "fixed";
    document.body.style.top = `-${savedScrollY}px`;
    document.body.style.left = "0";
    document.body.style.right = "0";

    if (scrollbarWidth > 0) {
      document.body.style.paddingRight = `${scrollbarWidth}px`;
    }
  }

  lockCount += 1;
}

export function unlockScroll() {
  // 二重解除を防ぐ
  if (lockCount === 0) return;

  lockCount -= 1;

  // 最後の1つが閉じたときだけ解除する
  if (lockCount === 0) {
    document.body.style.position = "";
    document.body.style.top = "";
    document.body.style.left = "";
    document.body.style.right = "";
    document.body.style.paddingRight = "";

    window.scrollTo(0, savedScrollY);
  }
}

これで、いくつモーダルが重なっても、すべて閉じたときに一度だけ背景が解除されます。lockCount === 0のときに早期returnしているのは、unlockが余計に呼ばれてカウントがマイナスになるのを防ぐためです。

再利用できる形で呼び出す

あとは、モーダルの開閉処理からlockScrollunlockScrollを呼ぶだけです。開く処理とロック、閉じる処理と解除を必ずセットにします。

scroll-lock-usage.js
import { lockScroll, unlockScroll } from "./scroll-lock.js";

const modal = document.getElementById("modal");
const openButton = document.getElementById("open");
const closeButton = document.getElementById("close");

function open() {
  modal.hidden = false;
  lockScroll();
}

function close() {
  modal.hidden = true;
  unlockScroll();
}

openButton.addEventListener("click", open);
closeButton.addEventListener("click", close);

// 背景(オーバーレイ)クリックでも閉じる
modal.addEventListener("click", (event) => {
  if (event.target === modal) {
    close();
  }
});
スクロールロックは「キーボード操作の閉じ込め」とは別

背景スクロールの固定と、Tabキーのフォーカスをモーダル内に留める「フォーカストラップ」は別の処理です。アクセシブルなモーダルにするには、両方とあわせてrole="dialog"aria-modal="true"、Escキーでの閉じる操作も実装します。モーダル本体の作り方はMicromodalの記事を参照してください。

よくある失敗

overflow:hiddenだけで済ませてスマホで背景が動く

PCでは正しく見えても、iOS Safariでは背景スクロールが止まりません。スマホ対応が必要ならposition: fixed方式を使い、必ず実機で確認してください。

padding-rightを忘れて画面がガタつく

スクロールバーが消える分だけ背景が右にずれます。padding-rightでスクロールバー幅を補うか、CSSのscrollbar-gutter: stableで予防します。

position:fixedにしたまま位置を戻さずスクロールが先頭に飛ぶ

position: fixed方式でtopのずらしやwindow.scrollTo()を省くと、モーダルを閉じた瞬間にページが一番上へ戻ってしまいます。開く前のscrollYを保存し、閉じるときに復元してください。

touchmoveを全部preventしてモーダル内も動かなくなる

古い手法としてtouchmovepreventDefault()で止める方法がありますが、これだけだとモーダル内のスクロールまで効かなくなります。現在はposition: fixedoverscroll-behaviorの組み合わせが扱いやすく確実です。

複数オーバーレイで数えずに早期解除する

モーダルの上にダイアログを重ねる構成で、内側を閉じた瞬間に解除すると、外側の背景が動きます。参照カウントで「最後の1つが閉じたとき」だけ解除してください。

よくある質問

Q結局どの方法を使えばいいですか?
APC専用ならoverflow: hiddenscrollbar-gutter: stableで十分です。スマホ(iOS)にも対応するならposition: fixed方式でスクロール位置を保存・復元し、overscroll-behavior: containを併用するのが確実です。
Qライブラリを使うべきですか?
Abody-scroll-lockのような専用ライブラリもあり、細かな端末差を吸収してくれます。要件が複雑(ネストしたスクロール領域が多いなど)なら検討に値しますが、本記事のposition: fixed方式と参照カウントで多くのケースは自前でまかなえます。
Qhtmlとbodyのどちらにoverflow:hiddenを付けますか?
A一般的にはbodyに付けます。ただしレイアウトの組み方によってはhtml側にスクロールがある場合もあります。position: fixed方式なら、bodyを固定することでどちらの構成でも背景を止められます。
Qスクロールバーの幅はなぜ計算で求めるのですか?
Aスクロールバーの幅はOSやブラウザ、設定によって異なり、固定値(例:17px)で決め打ちすると環境によってずれます。window.innerWidth - document.documentElement.clientWidthで実際の幅を測れば、どの環境でも正確に補えます。
Q閉じたあとにスクロール位置が少しずれます。
Aposition: fixedを外すタイミングとwindow.scrollTo()の順序を確認してください。先に固定スタイルをすべて解除してから、保存したsavedScrollYへ戻すと安定します。スムーズスクロールの設定が干渉する場合は、復元時のみ即時スクロールにします。

まとめ

  • PCだけならoverflow: hiddenで背景を止められますが、スクロールバー消失でガタつきます。
  • ガタつきはpadding-rightscrollbar-gutter: stableで防ぎます。
  • iOS Safariはoverflow: hiddenを無視するため、position: fixed方式が必要です。
  • position: fixed方式では、スクロール位置を保存して閉じるときに復元します。
  • モーダル内スクロールの連鎖はoverscroll-behavior: containで防ぎます。
  • 複数オーバーレイには参照カウントで対応し、最後の解除でのみ固定を外します。

スクロールロックは、見た目には小さな処理ですが、PCとスマホで挙動が大きく異なる繊細な領域です。position: fixedと位置復元、overscroll-behavior、参照カウントを押さえておけば、どの端末でも背景が静かに止まる快適なモーダルを実装できます。