モーダルウィンドウやスライド式のメニュー(ドロワー)を開いたとき、背景のページがスクロールできてしまうと、操作感がちぐはぐになります。モーダル内をスクロールしているつもりが裏のページが動いたり、閉じたときに表示位置が変わっていたりして、利用者を戸惑わせます。
そこで、オーバーレイ表示中は背景のスクロールを止める「スクロールロック」を実装します。一見するとbodyにoverflow: 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
基本は、モーダルを開くときにbodyへoverflow: hiddenを付け、閉じるときに外すだけです。スクロールできる領域がなくなるため、背景は動かなくなります。
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で補い、幅が変わらないようにすることです。スクロールバーの幅は次の式で求められます。
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: stableをhtmlに指定しておくと、スクロールバーの有無にかかわらず常にその幅の領域が確保されます。これだけで、スクロールロック時のガタつきを根本から防げます。padding-rightの計算が不要になるため、対応環境が許すなら第一候補です。
html {
scrollbar-gutter: stable;
}
問題2:iOSのSafariでは背景スクロールが止まらない
より厄介なのがこちらです。iOSのSafariでは、bodyにoverflow: hiddenを付けても、指でのスワイプによる背景スクロールが止まりません。モーダルを開いたまま背景を触ると、裏のページが普通に動いてしまいます。これはiOS Safariの長年の挙動で、overflow: hiddenだけでは解決しません。iOS Safariはビューポート周りに癖が多く、要素の高さが意図通りにならない100vhが効かない問題も同じ系統の注意点です。
PCのブラウザで動作確認すると問題なく見えるため、見落とされがちです。スクロールロックは必ず実機のiOS Safariでも確認してください。Androidの一部ブラウザでも、背景のバウンス(端での跳ね返り)が残ることがあります。
iOSにも効く position:fixed 方式(スクロール位置も保持)
iOSでも確実に背景を止めるには、bodyをposition: fixedにして、ページそのものを画面に固定します。ただしfixedにすると表示が一番上へ戻ってしまうため、現在のスクロール位置を保存し、見た目が変わらないようにtopでずらすのがポイントです。閉じるときは、保存した位置へスクロールを戻します。
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: fixedでbodyを固定すると、要素が通常の配置から外れて幅が縮むことがあります。これを防ぐためleft: 0とright: 0で左右を画面端に固定し、幅いっぱいに広げます。width: 100%を使わないのは、padding-rightでスクロールバー幅を補ったときに、width: 100%だと横方向へはみ出して水平スクロールバーが出てしまうためです。left: 0; right: 0;ならpadding-rightは内側に収まり、はみ出しません。top: -savedScrollYで、固定する前に見えていた位置をそのまま表示し続けます。閉じたあとにwindow.scrollTo()で元の位置へ戻せば、利用者は移動に気づきません。スクロール位置の指定方法は特定の位置までスクロールさせる方法も参考になります。
モーダル内のスクロールを背景に伝えない overscroll-behavior
内容が長いモーダルでは、モーダル自体をスクロールさせます。このとき、モーダルの端まで到達したスクロールが背景へ「連鎖」して、裏のページが動くことがあります。これを止めるのがCSSのoverscroll-behavior: containです。
.modal-body {
overflow-y: auto;
overscroll-behavior: contain; /* 背景へのスクロール連鎖を防ぐ */
}
overscroll-behavior: containは、その要素の中でスクロールが完結するようにし、端を越えたスクロールが親要素や背景へ波及するのを防ぎます。position: fixed方式と組み合わせると、モーダル内は普通にスクロールでき、背景は完全に静止する、という理想的な状態に近づきます。
複数のモーダルに対応する(参照カウント)
モーダルの上にさらに確認ダイアログを重ねるような画面では、単純なlockとunlockだと問題が起きます。内側のダイアログを閉じた時点でスクロールロックが解除され、まだ開いている外側のモーダルの背景が動いてしまうのです。
対策は、ロックの数を数える参照カウントです。ロックが0から1になったときだけ固定を適用し、1から0になったときだけ解除します。
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が余計に呼ばれてカウントがマイナスになるのを防ぐためです。
再利用できる形で呼び出す
あとは、モーダルの開閉処理からlockScrollとunlockScrollを呼ぶだけです。開く処理とロック、閉じる処理と解除を必ずセットにします。
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してモーダル内も動かなくなる
古い手法としてtouchmoveをpreventDefault()で止める方法がありますが、これだけだとモーダル内のスクロールまで効かなくなります。現在はposition: fixedとoverscroll-behaviorの組み合わせが扱いやすく確実です。
複数オーバーレイで数えずに早期解除する
モーダルの上にダイアログを重ねる構成で、内側を閉じた瞬間に解除すると、外側の背景が動きます。参照カウントで「最後の1つが閉じたとき」だけ解除してください。
よくある質問
overflow: hidden+scrollbar-gutter: stableで十分です。スマホ(iOS)にも対応するならposition: fixed方式でスクロール位置を保存・復元し、overscroll-behavior: containを併用するのが確実です。body-scroll-lockのような専用ライブラリもあり、細かな端末差を吸収してくれます。要件が複雑(ネストしたスクロール領域が多いなど)なら検討に値しますが、本記事のposition: fixed方式と参照カウントで多くのケースは自前でまかなえます。bodyに付けます。ただしレイアウトの組み方によってはhtml側にスクロールがある場合もあります。position: fixed方式なら、bodyを固定することでどちらの構成でも背景を止められます。window.innerWidth - document.documentElement.clientWidthで実際の幅を測れば、どの環境でも正確に補えます。position: fixedを外すタイミングとwindow.scrollTo()の順序を確認してください。先に固定スタイルをすべて解除してから、保存したsavedScrollYへ戻すと安定します。スムーズスクロールの設定が干渉する場合は、復元時のみ即時スクロールにします。まとめ
- PCだけなら
overflow: hiddenで背景を止められますが、スクロールバー消失でガタつきます。 - ガタつきは
padding-rightかscrollbar-gutter: stableで防ぎます。 - iOS Safariは
overflow: hiddenを無視するため、position: fixed方式が必要です。 position: fixed方式では、スクロール位置を保存して閉じるときに復元します。- モーダル内スクロールの連鎖は
overscroll-behavior: containで防ぎます。 - 複数オーバーレイには参照カウントで対応し、最後の解除でのみ固定を外します。
スクロールロックは、見た目には小さな処理ですが、PCとスマホで挙動が大きく異なる繊細な領域です。position: fixedと位置復元、overscroll-behavior、参照カウントを押さえておけば、どの端末でも背景が静かに止まる快適なモーダルを実装できます。

