CSSのscroll-snapで、縦に1画面ずつ切り替わるページを作る方法
CSS の scroll-snap を使うと、縦にスクロールしたときに、セクションが1画面ずつぴたっと切り替わるページを作れます。商品やサービスを1画面ずつ紹介するランディングページや、スライドのように1枚ずつ見せるページなど、全画面スクロール(フルスクリーンスクロール)と呼ばれる作りで使えます。JavaScript のライブラリは要りません。html 要素に scroll-snap-type: y mandatory を、各セクションに height: 100vh と scroll-snap-align: start を付けます。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Playwright 1.63.0、ヘッドレスの Chromium 153 です。幅360px・高さ640pxの画面でページを開き、指の操作は Chrome DevTools Protocol のタッチイベントで、マウスホイールは Playwright の mouse.wheel() で再現して、止まった位置(scrollTop)を記録しました。Firefox と Safari、スマートフォンの実機、アドレスバーが出たり隠れたりするときの動き、トラックパッドや実際のマウスでの操作は試していません。

html に scroll-snap-type、セクションに scroll-snap-align を付ける
HTML は、セクションを並べただけです。
<main>
<section class="screen" id="s1"><h2>1ページ目</h2><p>上へスワイプすると、次のセクションが画面いっぱいに表示されます。</p></section>
<section class="screen" id="s2"><h2>2ページ目</h2><p>途中で指を離しても、セクションの上端で止まります。</p></section>
<!-- 3〜5ページ目も同じ形 -->
</main>
CSS は次のとおりです。
/* ページ全体のスクロールを、各セクションの上端で止める */
html {
scroll-snap-type: y mandatory;
}
body {
margin: 0;
color: #1f2a30;
}
/* 1画面ぶんの高さのセクション */
.screen {
box-sizing: border-box;
height: 100vh;
padding: 24px;
display: flex;
flex-direction: column;
justify-content: center;
scroll-snap-align: start;
scroll-snap-stop: always;
}
.screen:nth-child(odd) {
background: #eefafd;
}
.screen h2 {
margin: 0 0 12px;
font-size: 28px;
color: #067a98;
}
.screen p {
margin: 0;
font-size: 16px;
line-height: 1.7;
}
スクロールを止める動きに関わるのは、次の4つです。
htmlのscroll-snap-type: y mandatory:縦方向のスクロールを、必ずどこかの止まる位置に合わせる.screenのheight: 100vh:セクション1つを画面の高さにする.screenのscroll-snap-align: start:セクションの上端を、止まる位置にする.screenのscroll-snap-stop: always:強く払っても、セクションを飛ばさない(後の節で説明します)
MDN の scroll-snap-type のページによると、mandatory はスクロールしていないときに必ず止まる位置に合わせる値で、proximity は止まる位置に合わせるかどうかをブラウザーが決める値です。検証環境の画面(高さ640px)では、セクションの高さはどれも640pxになり、止まる位置は0、640、1280、1920、2560の5か所です。
body に書いても止まらない
scroll-snap-type は、body ではなく html に書きます。html には何も書かず、body に scroll-snap-type: y mandatory を書いた版も試したところ、指を離した位置でそのまま止まりました。スナップを付けていない版と同じ結果です。
CSS Scroll Snap の仕様には、ルート要素(html)に書いた scroll-snap-type を、ページ全体のスクロールに使うと書かれています。overflow と違い、body に書いた値はページ全体には伝わりません。
指を離した位置と、止まるセクション
1ページ目から指を上へ動かして離し、止まった scrollTop を比べました。指は12回に分けて、40msおきに動かしています。
| 指で動かした距離 | y mandatory(記事の CSS) |
y proximity |
スナップなし |
|---|---|---|---|
| 100px | 0(1ページ目) | 0(1ページ目) | 85 |
| 250px | 0(1ページ目) | 235 | 235 |
| 400px | 640(2ページ目) | 385 | 385 |
mandatory では、セクションの途中で止まることはありませんでした。250pxでは1ページ目に戻り、400pxでは2ページ目に進んでいます。止まる位置は、0と640のうち近いほうです。proximity では、100pxのときだけ1ページ目に戻り、250pxと400pxでは指を離したところで止まりました。セクションの途中で止まるので、1画面ずつ見せるページには mandatory が合います。
スクリプトで途中の位置へ送ったときも、mandatory では近いセクションに合わせられました。scrollTo({ top: 400 }) では640に、scrollTo({ top: 1000 }) では1280になりました。
強く払っても1画面ずつにする
強く払うと、勢いで2画面先まで進むことがあります。1回の操作で1画面ずつにしたいときは、セクションに scroll-snap-stop: always を付けます。MDN の scroll-snap-stop のページによると、always にすると、スクロールの途中にある止まる位置を素通りできなくなります。初期値の normal では、素通りできます。
1ページ目から、指を上へ3回に分けて16msおきに動かして離す操作を、それぞれ20回試しました。
| 指で動かした距離 | always(記事の CSS) |
normal |
|---|---|---|
| 400px | 2ページ目で20回 | 2ページ目で14回、3ページ目まで6回 |
| 450px | 2ページ目で17回、3ページ目まで3回 | 3ページ目まで20回 |
normal では、450px払うと20回とも3ページ目まで進みました。always にすると、ほとんどが2ページ目で止まります。ただし検証環境の Chromium では、450px払ったときに20回中3回、always でも3ページ目まで進みました。どんな操作でも必ず1画面ずつになる、とまでは言えない結果です。
キーボード、マウスホイール、ページ内リンクでも切り替わる
1ページ目から、キーを1つずつ押して止まった位置を記録しました。
| 押したキー | y mandatory |
スナップなし |
|---|---|---|
| PageDown | 640 | 560 |
| PageDown | 1280 | 1120 |
| Space | 1920 | 1680 |
| ↓ | 2560 | 1720 |
| ↓ | 2560 | 1760 |
| PageUp | 1920 | 1200 |
| Shift+Space | 1280 | 640 |
| End | 2560 | 2560 |
| Home | 0 | 0 |
スナップなしでは、PageDown は1画面より少し短い560pxずつ、↓キーは40pxずつ動きます。mandatory では、PageDown、PageUp、スペースキー、Shift+スペースキーのどれも、ちょうど1画面ずつ切り替わりました。↓キーも、1回押しただけで次のセクションまで進みました。
マウスホイールは、Playwright の mouse.wheel() で1ページ目から下へ回しました。回した量(deltaY)が100や300のときは1ページ目のままで、330と400のときは2ページ目に進みました。この範囲では、指で動かしたときと同じく、0と640のうち近いほうに止まりました。1回の操作で deltaY を1000にしたときは3ページ目(1280)、2000にしたときは4ページ目(1920)まで進みました。scroll-snap-stop: always を付けていても、1回のホイール操作で大きく回すと、セクションを飛ばすことがあります。
ページ内リンクも使えます。<a href="#s4"> を押すと、scrollTop は4ページ目の上端と同じ1920になりました。各セクションに id を付けておけば、目次などのリンクから、目的のセクションへ移れます。
画面より長いセクションも、最後まで読める
セクションの中身が画面に収まらないときは、height: 100vh の代わりに min-height: 100vh にして、中身に合わせて伸ばします。MDN のスクロールスナップの基本概念には、子要素の中身が親要素からあふれる場合は、あふれた部分までスクロールできなくなるので mandatory を使わないように、という注意があります。
一方、仕様の、スナップする要素がスクロールする領域より大きいときを定めた節では、セクションが画面より大きく、画面全体を覆っている間は、どの位置で止まってもよいとされています。そこで、3ページ目だけを高さ1527pxに伸ばし、y mandatory のまま試しました。3ページ目の上端は1280、下端が画面の下端にそろう位置は2167、4ページ目の上端は2807です。
3ページ目の上端から指を上へ200pxずつ5回動かすと、止まった位置は1465、1650、1835、2020、2167でした。3ページ目の中では、セクションの上端に戻されることなく、指を離したところで止まります。scrollTo() で1500や1900に送っても、その位置のままでした。
3ページ目の下端(2167)まで来ると、そこから先は動きにくくなりました。続けて300px動かしても2167のままで、400px動かしたところで4ページ目(2807)に進んでいます。キーボードでも、3ページ目の中では↓キーで40px、PageDown で560px動き、下端の2167で一度止まってから、次の PageDown で4ページ目に移りました。
検証環境の Chromium では、min-height で伸ばした長いセクションも、mandatory のまま最後まで読めました。MDN の注意にあるような、見られない部分は出ませんでした。
画面の高さが変わると、表示中のセクションに合わせ直す
3ページ目(1280)で止めた状態から、画面の高さを640pxから500pxに変えました。セクションも500pxずつになり、3ページ目の上端は1000に移ります。mandatory の版では scrollTop も1000になり、3ページ目の上端が画面の上端にそろったままでした。スナップなしの版では1280のままで、3ページ目の途中が表示されます。
仕様の、レイアウトが変わったあとのスナップのし直しを定めた節には、レイアウトが変わる前にスナップしていた場合、変わったあとも同じ要素にスナップし直すと書かれています。
セクションの高さに使っている vh について、MDN の <length> のページでは、vh は lvh(大きいビューポート)と同じで、ブラウザーのツールバーが表示されている間は、全画面の表示で中身が隠れることがあると説明されています。小さいビューポートに合わせる svh、いまの大きさに合わせる dvh という単位もあります。
対応しているブラウザー
@mdn/browser-compat-data 8.1.8 の互換性データでは、scroll-snap-type と scroll-snap-align は Chrome 69、Edge 79、Safari 11(iOS の Safari も11)から対応しています。Firefox は scroll-snap-align が68から、scroll-snap-type が99から(68からは一部のみの対応)です。scroll-snap-stop は Chrome 75、Edge 79、Firefox 103、Safari 15 からの対応です。





