CSSで、横スクロールのカードをぴたっと止める方法

​​CSS​HTML,Playwright

横に並べたカードを、スクロールし終えたときにカードの左端でぴたっと止めるには、CSS の scroll-snap を使います。JavaScript は要りません。リストに scroll-snap-type、カードに scroll-snap-align を付けるのが基本で、止まる位置をリストの余白に合わせたいときは scroll-padding-inline、強く払っても1枚ずつ進ませたいときは scroll-snap-stop を足します。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Playwright 1.63.0、Chromium 153 です。幅360pxの画面で HTML と CSS のページを開き、指の操作を Chrome DevTools Protocol のタッチイベントで再現して、止まった位置(scrollLeft)を記録しました。Firefox と Safari、スマートフォンの実機、トラックパッドやマウスホイールでの操作は試していません。

横に並んだカードを、指で左へ3回動かす。1回目は左へ100px動かして離すと、カード1の左端に戻る。2回目は160px動かして離すと、カード2の左端がリストの左の余白にそろう位置で止まり、下の「止まった位置:カード 2 の左端」がピンクになる。3回目は60px動かして離すと、カード2の左端に戻る
指で動かして離すと、カードの左端の位置まで動いて止まる様子

リストに scroll-snap-type、カードに scroll-snap-align を付ける

HTML は、カードを <ul> に並べただけです。

html
<section aria-label="新着の記事">
  <ul class="card-list">
    <li class="card"><h3>カード 1</h3><p>横にスクロールすると、カードの左端で止まります。</p></li>
    <li class="card"><h3>カード 2</h3><p>次のカードが右に少し見えるので、続きがあると分かります。</p></li>
    <!-- カード 3〜6 も同じ形 -->
  </ul>
</section>

CSS は次のとおりです。

css
/* 横スクロールで、カードの左端にぴたっと止まるリスト */
.card-list {
  display: flex;
  gap: 12px;
  margin: 0;
  padding: 0 16px 12px;
  list-style: none;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-padding-inline: 16px;
}

.card {
  flex: 0 0 75%;
  scroll-snap-align: start;
  box-sizing: border-box;
  padding: 16px;
  border: 1px solid #b8e6ef;
  border-radius: 12px;
  background: #eefafd;
}

.card h3 {
  margin: 0 0 8px;
  font-size: 18px;
  color: #067a98;
}

.card p {
  margin: 0;
  font-size: 14px;
  line-height: 1.6;
  color: #1f2a30;
}

止まる動きを決めているのは、次の3行です。

  • リストの scroll-snap-type: x mandatory:横方向に、必ずどこかの止まる位置に合わせる
  • カードの scroll-snap-align: start:カードの左端(先頭)を、止まる位置にする
  • リストの scroll-padding-inline: 16px:止まる位置を、リストの左端から16px内側にずらす

scroll-snap-type の説明によると、mandatory はスクロールしていないときに必ず止まる位置に合わせる値で、proximity は止まる位置に合わせるかどうかをブラウザに任せる値です。止まる位置は、scroll-snap-align の start、center、end で、カードの先頭、中央、末尾のどれに合わせるかを選びます。

カードの幅を flex: 0 0 75% にしているのは、次のカードを右に少し見せて、続きがあると分かるようにするためです。幅360pxの画面では、リストの中身の幅(360pxから左右の余白を引いた328px)の75%で、カードの幅は246pxになりました。

指で動かして離すと、近いほうのカードに止まる

指を左へ、40msおきに12回に分けて動かし、離したあとに止まった位置を記録しました。比べるために、scroll-snap-type: none にした版も試しています。

指で動かした距離 x mandatory(記事の CSS) none
60px 0(カード1の左端) 45
160px 258(カード2の左端) 145
330px 258(カード2の左端) 315

数字は止まったときの scrollLeft です。カード1枚とすき間(246px+12px)で、止まる位置は258pxずつ並びます。x mandatory では、どの距離でも0か258で止まり、カードの途中で止まることはありませんでした。60pxでは元のカード1に戻り、160pxではカード2に進みました。330px動かしたときは、カード2(258)とカード3(516)のうち近いカード2に戻っています。none の版では、指を離したところで止まり、カードが左端で切れたままになりました。

止まる位置へは、一度に飛ぶのではなく、少しずつ動きます。160px動かして離したあと、50msおきに scrollLeft を読むと、151、201、243、258と増えて止まりました。冒頭の GIF は、同じような操作を100px、160px、60pxの順で撮ったものです。

スクリプトで途中の位置へ動かしたときも、同じように止まる位置に合わせられました。scrollTo({ left: 100 }) では scrollLeft が0に、scrollTo({ left: 140 }) では258になりました。none の版では、指定した100や140のままです。

止まる位置を、リストの左の余白に合わせる

リストの左右に16pxの padding を付けているので、最初のカードは左端から16px内側に並びます。ところが scroll-padding-inline を付けないと、スクロールして止まったときのカードの左端は、リストの左端ぴったり(0px)になりました。最初のカードも、60px動かして離すと scrollLeft が16になり、左の余白がなくなりました。

scroll-padding-inline: 16px を付けると、止まる位置がリストの左端から16px内側になり、どのカードに止まっても左に16pxの余白が残りました。scroll-padding-inline の説明にあるとおり、この値は、リストの見えている範囲(スクロールポート)の左右の辺から、止まる位置までの距離です。リストの padding と同じ値にしておくと、最初のカードとそれ以降のカードで、左の余白がそろいます。

上下2段の比較。どちらもカード2に止まった状態。上の「scroll-padding-inline: 16px」は、カード2の左に少し余白があり、その余白にカード1の右端がわずかに見えている。下の「scroll-padding-inline なし」は、カード2の左端が画面の左端にぴったり付いている
カード2に止めたときの違い。scroll-padding-inline を付けると(上)、左に16pxの余白が残る

上の段で、カード2の左の余白にカード1の右端が4pxだけ見えているのは、すき間(12px)が余白(16px)より狭いからです。gap を16pxにして scroll-padding-inline と同じ値にすると、カード1の右端はリストの左端ちょうど(0px)になり、前のカードは見えなくなりました。

払っても1枚ずつ進ませるなら scroll-snap-stop: always

強く払ったときは、勢いで何枚か先まで進むことがあります。1回の操作で1枚ずつ進ませたいときは、カードに scroll-snap-stop: always を付けます。

css
.card {
  scroll-snap-stop: always;
}

scroll-snap-stop の説明では、always は止まる位置を「通り過ぎる」ことを許さない値で、初期値の normal は通り過ぎてもよい値です。

指を左へ200px、16msおきに3回に分けて動かして離す操作を、normal と always でそれぞれ10回試しました。

normal(初期値) always
カード2(258)で止まった回数 5回 10回
カード3(516)まで進んだ回数 5回 0回
scrollBy({ left: 700 }) で止まった位置 774(カード4) 258(カード2)

normal では、同じ操作でもカード2で止まるときとカード3まで進むときがありました。always では、10回ともカード2で止まりました。スクリプトの scrollBy({ left: 700 }) でも、normal は700に近いカード4へ動き、always はカード2で止まりました。

最後のカードは、右端まで来たところで止まる

scroll-snap-align: start にしていても、最後のカードは左端までは来ません。それ以上右へスクロールできないからです。右端までスクロールすると、scrollLeft は最大の1208で止まり、カード6の左端はリストの左端から98pxの位置、右には16pxの余白が残りました。

カードを画面の真ん中に止めたいときは、scroll-snap-align: center にします。この版で、指を左へゆっくり200pxずつ動かして離すと、カード2〜5では左右に57pxずつの余白ができて、カードが中央で止まりました。カード1とカード6は、それ以上スクロールできないため中央には来ず、start の場合と同じ位置(カード1は左の余白が16px、カード6は右の余白が16px)で止まりました。

キーボードの矢印キーでも、カード1枚ずつ動く

リストに tabindex を付けなくても、Chromium 153 では Tab キーでリストにフォーカスが移りました。そのまま右の矢印キーを押すと scrollLeft が258、516と、カード1枚ずつ動いて左端に止まり、左の矢印キーで258に戻りました。

対応しているブラウザ

MDN の互換性データ(@mdn/browser-compat-data 8.1.8)で、この記事で使ったプロパティを調べました。

プロパティ Chrome Firefox Safari iOS の Safari
scroll-snap-type 69 99 11 11
scroll-snap-align 69 68 11 11
scroll-snap-stop 75 103 15 15
scroll-padding-inline 69 68 15 15

scroll-snap-stop と scroll-padding-inline は、Safari では15からの対応です。scroll-snap-type と scroll-snap-align は、Safari 11から対応しています。

​Posted by 86