モーダルの中の長い本文をスクロールしていたら、下まで読み切ったところで背景のページまで動き出してしまった。そんな経験はないでしょうか。これは「スクロールチェーン」と呼ばれるブラウザーの標準的な挙動で、CSS の overscroll-behavior プロパティ一つで止められます。この記事では auto / contain / none の3つの値の違いと、軸ごとに指定する overscroll-behavior-x / overscroll-behavior-y、そしてスマートフォンの「引っ張って更新」やスクロールのバウンスを制御する方法までを、動かせるデモ付きで解説します。
目次
スクロールチェーンとは
スクロールチェーンは、あるスクロール領域を端まで動かしたあと、続きのスクロールがその外側の領域に引き継がれるブラウザーの挙動です。「連鎖」という名前のとおり、内側の要素 → その親 → ページ全体、という順にスクロールが伝わっていきます。
ページの中に一つしかスクロール領域がなければ気になりませんが、モーダルやドロワー、チャットの履歴欄のようにページの上に重ねたスクロール領域があると話は変わります。利用者は重ねた部分だけを操作しているつもりなのに、端に達した瞬間に背景がずれ始め、モーダルを閉じたら読んでいた位置が変わっていた、ということが起こります。
下のデモで実際の動きを確かめてみてください。プレビュー全体(外側のページ)も縦にスクロールする状態にしてあります。A の枠を下まで動かしてからさらにスクロールを続けると、背景のページが動き出します。B の枠は端で止まり、外側には伝わりません。この違いを生んでいるのが overscroll-behavior: contain の一行です。
<p class="note">↓ この外側(プレビュー全体)も縦にスクロールします</p>
<p class="label">A: 指定なし(auto)</p>
<div class="box box--auto">
<p>この中を下までスクロールして、そのまま指やホイールを動かし続けてください。</p>
<p>枠の端まで来ると、続きのスクロールが外側のページに伝わって背景ごと動きます。</p>
<p>これがスクロールチェーンです。</p>
<p>ここが枠の終わりです。</p>
</div>
<p class="label">B: overscroll-behavior: contain</p>
<div class="box box--contain">
<p>こちらも中を下までスクロールしてみてください。</p>
<p>端に到達したあとは、いくら動かしても外側のページは動きません。</p>
<p>スクロールがこの枠の中で止まります。</p>
<p>ここが枠の終わりです。</p>
</div>
<div class="filler">
<p>ここから下は外側のページのコンテンツです。</p>
<p>スクロールチェーンが起きると、この部分が勝手に流れていきます。</p>
<p>外側のページの終わりです。</p>
</div>
body {
margin: 0;
padding: 16px;
font-family: sans-serif;
font-size: 13px;
line-height: 1.7;
color: #1e293b;
background: #f8fafc;
}
.note {
margin: 0 0 12px;
color: #64748b;
}
.label {
margin: 0 0 6px;
font-weight: bold;
color: #3730a3;
}
/* 中身がはみ出す高さにして、枠そのものをスクロールできるようにする */
.box {
height: 120px;
overflow-y: auto;
margin-bottom: 16px;
padding: 0 12px;
background: #fff;
border: 1px solid #6366f1;
border-radius: 6px;
}
.box p {
margin: 12px 0;
}
/* A: 既定値。端まで来ると外側のページにスクロールが伝わる */
.box--auto {
overscroll-behavior: auto;
}
/* B: 端まで来てもスクロールを外側に伝えない */
.box--contain {
overscroll-behavior: contain;
border-color: #22c55e;
}
.filler {
padding: 12px;
background: #e0e7ff;
border-radius: 6px;
}
.filler p {
margin: 12px 0;
}
auto・contain・none の違い
overscroll-behavior は、スクロール領域の端に到達したときにブラウザーが何をするかを決めるプロパティです。指定できる値は3つで、初期値は auto です。
| 値 | 端に達したときの動き |
|---|---|
auto | 初期値。外側のスクロール領域に連鎖し、その要素自身のオーバースクロール効果(バウンスや引っ張って更新)も通常どおり働く |
contain | 外側への連鎖は起きない。ただし、その要素自身のオーバースクロール効果はそのまま残る |
none | 外側への連鎖が起きず、その要素自身のオーバースクロール効果も抑止される |
ここで言う「オーバースクロール効果」とは、端を越えて動かそうとしたときにブラウザーが見せる反応のことです。iOS でページの端がゴムのように跳ね返る動き(バウンス)、Android のスクロール端で出る光のような表示(グロー)、画面を下に引っ張ると再読み込みされる「引っ張って更新(pull-to-refresh)」などが該当します。
つまり contain と none の差は、「連鎖だけ止めるか、その要素の跳ね返りや更新まで止めるか」です。モーダルやドロワーのように「背景が動かなければよい」場面は contain、地図やキャンバスのように端での跳ね返りも邪魔になる場面は none、と考えると選びやすくなります。
.modal__body {
/* まずスクロールできる要素にする(ここが前提) */
max-height: 70vh;
overflow-y: auto;
/* 端まで来ても背景のページに連鎖させない */
overscroll-behavior: contain;
}
大事なのはコメントに書いた前提のほうです。overscroll-behavior はスクロールできる要素に対してだけ働くので、overflow の指定とセットで書く癖をつけておくと失敗が減ります。
横方向・縦方向を別々に指定する
軸ごとに指定したいときは overscroll-behavior-x(横方向)と overscroll-behavior-y(縦方向)を使います。overscroll-behavior はこの2つをまとめた一括指定で、値を2つ書くと1つ目が横方向、2つ目が縦方向になります。値が1つだけなら両方向に同じ値が適用されます。
/* 縦横どちらも連鎖させない */
.panel {
overscroll-behavior: contain;
}
/* 横だけ連鎖させない。縦はページに伝わったままにする */
.carousel {
overscroll-behavior-x: contain;
}
/* 一括指定で書くとこう。1つ目が横、2つ目が縦 */
.carousel {
overscroll-behavior: contain auto;
}
軸ごとの指定が活きるのが、横スクロールのカルーセルです。カードを横に並べた領域は横にしかスクロールしないので、縦の指を止めてしまうとページ自体が読み進められなくなります。横だけ contain にしておけば、カードの端まで来ても隣の領域やページが横に動くことはなく、縦のスクロールはこれまでどおりページに伝わります。
下のデモは、外側のページも横に長くしたカルーセルです。A のカルーセルを右端まで動かしてからさらに動かすと、ページ全体が横にずれます。B は overscroll-behavior-x: contain があるのでカルーセルの中で止まります。
<p class="note">↓ この外側(プレビュー全体)も横にスクロールします</p>
<p class="label">A: 指定なし(auto)</p>
<div class="carousel carousel--auto">
<div class="card">1</div>
<div class="card">2</div>
<div class="card">3</div>
<div class="card">4</div>
<div class="card">5</div>
</div>
<p class="label">B: overscroll-behavior-x: contain</p>
<div class="carousel carousel--contain">
<div class="card">1</div>
<div class="card">2</div>
<div class="card">3</div>
<div class="card">4</div>
<div class="card">5</div>
</div>
<p class="wide">外側のページも横に長いので、A ではカルーセルの端まで来るとこの帯ごと横に流れます。</p>
body {
margin: 0;
padding: 16px;
font-family: sans-serif;
font-size: 13px;
color: #1e293b;
background: #f8fafc;
}
.note {
margin: 0 0 12px;
color: #64748b;
}
.label {
margin: 0 0 6px;
font-weight: bold;
color: #3730a3;
}
/* 横並びのカードを横スクロールさせる */
.carousel {
display: flex;
gap: 8px;
overflow-x: auto;
margin-bottom: 16px;
padding-bottom: 8px;
}
.card {
flex: 0 0 60%;
height: 70px;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
border: 1px solid #6366f1;
border-radius: 6px;
font-weight: bold;
}
/* B: 横方向だけチェーンを止める。縦のスクロールはそのまま外側に伝わる */
.carousel--contain {
overscroll-behavior-x: contain;
}
.carousel--contain .card {
border-color: #22c55e;
}
/* 外側のページを横に長くして、チェーンが起きる状態を作る */
.wide {
width: 700px;
margin: 0;
padding: 12px;
background: #e0e7ff;
border-radius: 6px;
}
なお、書字方向に合わせて軸を選ぶ overscroll-behavior-inline(行方向)と overscroll-behavior-block(ブロック方向)も用意されています。横書きの日本語や英語では inline が横・block が縦に対応し、-x / -y と同じ結果になります。縦書きや右から左に読む言語も想定するなら、こちらを使うと指定を書き換えずに済みます。
スマホの引っ張って更新とバウンスを制御する
overscroll-behavior がもう一つ解決してくれるのが、モバイル特有のオーバースクロール効果です。ページを一番上まで戻したところで下に引っ張ると再読み込みが始まる「引っ張って更新」は便利な機能ですが、自前で下方向のドラッグ操作を実装しているアプリのような画面では、意図せず更新が走って作業中の状態が消えてしまいます。
ページ全体の引っ張って更新を止める
ページ全体の挙動を変えたいときは、個々の要素ではなくルートのスクロール領域、つまり body(または html)に指定します。縦方向の話なので overscroll-behavior-y を使います。
body {
/* 引っ張って更新を無効にする */
overscroll-behavior-y: contain;
}
Android の Chrome ではこれで引っ張って更新が働かなくなります。contain なので、端まで来たときの跳ね返りやグロー表示は残ります。跳ね返りも含めて動きを完全に止めたい場合は none にします。ただし端に達したことが視覚的に分かりにくくなるため、全画面のゲームや地図のようにページらしいスクロールをそもそも期待させない画面以外では、contain にとどめておくほうが親切です。
端末やブラウザーによる差に注意する
オーバースクロール効果そのものはブラウザーごとに実装が違うため、同じ指定でも見え方は一致しません。引っ張って更新は Android の Chrome にはありますが iOS の Safari にはなく、代わりに Safari では端がゴムのように跳ね返ります。overscroll-behavior は Chrome・Firefox・Safari のいずれでも使えますが(Safari は 16 から対応)、ページ全体の跳ね返りをどこまで抑えられるかは環境差が出やすい部分です。ここを触ったときは、必ず実機で確認してください。
一方、要素内のスクロールを外に漏らさない contain の効果は、どのブラウザーでも安定して得られます。まずは連鎖の抑止を目的に使い、跳ね返りや更新の抑止は「効いたら儲けもの」くらいの構えでいると、環境差に振り回されずに済みます。
実際のUIでの使いどころ
ここからは、実務で overscroll-behavior が必要になる典型的な場面を見ていきます。共通するのは「ページの上に、独立したスクロール領域を重ねている」という構図です。
モーダルとドロワー
もっとも出番が多いのがモーダルです。背景を暗くするオーバーレイの上に本文を置き、本文が長いときだけスクロールさせる、という作りが一般的ですが、この本文が端まで来ると背景のページが動き出します。本文のスクロール領域に overscroll-behavior: contain を付けるだけで解決します。画面の横から出てくるドロワーメニューも同じです。
/* 画面全体を覆うオーバーレイ */
.modal-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
/* オーバーレイ自体もスクロール領域にして連鎖を止める */
overflow-y: auto;
overscroll-behavior: contain;
}
.modal {
width: min(90%, 600px);
max-height: 80vh;
overflow-y: auto;
overscroll-behavior: contain; /* 本文側にも指定する */
background: #fff;
border-radius: 8px;
}
本文だけでなくオーバーレイにも指定しているのがポイントです。overscroll-behavior は指定した要素のスクロールが外に漏れるのを防ぐものなので、モーダルの外側(暗くなった余白の部分)でホイールを回したときの背景スクロールまでは止められません。オーバーレイを画面いっぱいのスクロール領域にしたうえで contain を付けておくと、余白を操作したときも背景が動かなくなります。
従来はモーダルを開いている間だけ JavaScript で body に overflow: hidden を付ける方法がよく使われてきました。この方法はスクロール位置が飛ぶ、スクロールバーが消えてレイアウトが横にずれるといった副作用が付きものです。CSS だけで済むなら overscroll-behavior のほうが手軽です。
チャット欄や候補リスト
ページに埋め込まれたチャットの履歴欄、入力欄の下に出てくる検索候補のリスト、通知のドロップダウンなども同じ問題を抱えます。これらは高さが小さいぶん端に到達しやすく、少し勢いよくスクロールしただけで背景が流れ始めます。狙った要素だけを動かしたい小さなスクロール領域には、ひととおり overscroll-behavior: contain を入れておくとよいでしょう。
横スクロールとブラウザーの「戻る」
横スクロールの領域には、連鎖とは別の困りごとがあります。ノートパソコンのトラックパッドやタッチ操作では、横方向に大きく動かすと「戻る」「進む」のナビゲーションとして扱われることがあり、カルーセルを勢いよく送っただけで前のページに戻ってしまう、という事故が起きます。overscroll-behavior-x: none を指定しておくと、こうしたスワイプによる移動を抑えられます。連鎖だけを止めたいなら contain、ナビゲーションまで抑えたいなら none と使い分けてください。
指定しても効かないときに見るところ
「書いたのに背景が動く」というときは、たいてい原因が決まっています。上から順に確認していけば、ほとんどの場合はここで解決します。
指定した要素がスクロールできる要素になっていない
もっとも多い原因がこれです。overscroll-behavior はスクロール領域を持つ要素にだけ働くプロパティで、それ以外の要素に書いても単に無視されます。overflow が初期値の visible のままでは、中身がはみ出していてもその要素はスクロールしません(はみ出したぶんはそのまま外に描画され、結果としてページ側がスクロールします)。まず overflow-y: auto や overflow: hidden などを指定して、その要素自身がスクロール領域になっているかを確かめてください。
ブラウザーの検証ツールで対象の要素を選び、スクロールバーが出ているか、あるいはその要素だけを動かせるかを見るのが手早い確認方法です。動かせないなら、overscroll-behavior 以前の問題です。
実際に動いている要素と、指定した要素がずれている
モーダルのように入れ子が深い構造では、外枠の .modal に書いたつもりが、実際にスクロールしているのは中の .modal__body だった、ということが起こります。連鎖を止めるのはスクロールしている当の要素なので、指定の場所がひとつずれるだけで効かなくなります。
逆に、要素の中では止まっているのにモーダルの外側でホイールを回すと背景が動く、という場合は指定が足りていません。前述のとおり、画面全体を覆うオーバーレイのほうにもスクロール領域と contain を用意する必要があります。
ページ全体の挙動を要素側で変えようとしている
引っ張って更新やページ端の跳ね返りは、ページそのもののスクロール領域で起きる現象です。ページの中のある <div> に none を書いても、ページ全体の挙動は変わりません。この場合は body や html に指定します。
また、要素側で連鎖を止めれば、その要素を操作したときにページの端まで到達しなくなるため、結果的に引っ張って更新も起きなくなります。「モーダルを開いたまま下に引っ張ると更新される」といった症状は、body ではなくモーダル側の contain で直ることが多いので、どちらのスクロール領域で起きているのかを切り分けてから対処してください。
まとめ
overscroll-behavior は、スクロール領域の端に到達したときのブラウザーの振る舞いを決めるプロパティです。初期値の auto は外側の領域へスクロールを連鎖させ、contain は連鎖だけを止め、none は連鎖に加えて跳ね返りや引っ張って更新といったオーバースクロール効果も抑えます。軸ごとに変えたいときは overscroll-behavior-x / overscroll-behavior-y、一括指定なら1つ目が横・2つ目が縦です。
使いどころは、モーダル・ドロワー・チャット欄・カルーセルのように、ページの上に別のスクロール領域を重ねている場所です。迷ったら contain を選び、跳ね返りやスワイプでの画面移動まで抑えたいときだけ none にします。そして効かないときは、まず指定した要素が本当にスクロールしているかを疑ってください。overflow とセットで書く、という一点を守るだけで、このプロパティはとても素直に働いてくれます。