CSSのscroll-snapで、縦に1画面ずつ切り替わるページを作る方法

​​CSS​CSS Scroll Snap,HTML,Playwright

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、スマートフォンの実機、アドレスバーが出たり隠れたりするときの動き、トラックパッドや実際のマウスでの操作は試していません。

縦に5つのセクションが並ぶページを、指で上へ3回動かす。1回目は少しだけ動かして離すと、1ページ目に戻る。2回目は長めに動かして離すと、2ページ目が画面いっぱいに止まり、下の「止まったページ」の表示がピンクになって2ページ目に変わる。3回目は強く払うと、3ページ目で止まる
指で動かして離すと、1画面ずつ切り替わる様子。上の「スワイプ n回目」、指の丸、下の「止まったページ」の表示は録画のために足したもの

html に scroll-snap-type、セクションに scroll-snap-align を付ける

HTML は、セクションを並べただけです。

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 は次のとおりです。

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 からの対応です。

​Posted by 86