- CSS
CSS scroll-snapで横スクロールカルーセル|JSなしの完成版
CSS scroll-snapとは、スクロールが止まる位置を要素の端や中央にぴたりとそろえる仕組みです。 親に overflow-x: auto と scroll-snap-type: x mandatory、子に scroll-snap-align を書くだけで、JavaScriptなしの横スクロールカルーセルになります。
この記事では、コピペで動く完成版(ドット・矢印つき)を2種類用意しました。1つは ::scroll-button() と ::scroll-marker を使ったCSSだけの版、もう1つは未対応ブラウザでも同じ見た目で動く最小JSつきの版です。どちらも Chromium 141(Playwright 1.56)で動かし、スクロール位置・ドットの状態・キーボード操作を数値で確認した結果を載せています。スナップが効かないときの原因も、実際に再現して計測しました。
CSS scroll-snapの早見表
| やりたいこと | 書く場所 | 書き方 |
|---|---|---|
| 横スクロールでぴたりと止める | 親(スクロールする要素) | overflow-x: auto; scroll-snap-type: x mandatory; |
| 近いときだけ止める | 親 | scroll-snap-type: x proximity; |
| 止める位置を決める | 子 | scroll-snap-align: start;(center / end) |
| 1枚ずつ必ず止める | 子 | scroll-snap-stop: always; |
| 止まる位置を内側にずらす(固定ヘッダーや余白ぶん) | 親 | scroll-padding-inline: 24px; |
| 特定の子だけ止まる位置をずらす | 子 | scroll-margin-inline: 16px; |
| 子が縮まないようにする | 子 | flex: 0 0 80%;(flex-shrink: 0 が要点) |
| なめらかに動かす | 親 | scroll-behavior: smooth;(動きを減らす設定に配慮) |
| スクロールバーを隠す | 親 | scrollbar-width: none; |
| ページ全体へのスクロール連鎖を止める | 親 | overscroll-behavior-x: contain; |
| 矢印ボタンをCSSで出す | 親 | .track::scroll-button(inline-end) { content: "›" / "次へ"; } |
| ドットをCSSで出す | 親と子 | 親に scroll-marker-group: after;、子に ::scroll-marker { content: ""; } |
| 今のドットに色をつける | 子 | ::scroll-marker:target-current { background: ...; } |
| 機能 | Chrome / Edge | Firefox | Safari |
|---|---|---|---|
scroll-snap-type / scroll-snap-align |
対応 | 対応 | 対応 |
scroll-snap-stop |
75 / 79 | 103 | 15 |
scroll-padding |
69 / 79 | 68 | 14.1 |
scrollbar-width |
121 | 64 | 18.2 |
::scroll-button() / ::scroll-marker / scroll-marker-group / :target-current |
135 | 未対応 | 未対応 |
対応状況は MDN の browser-compat-data 8.1.4(2026-10-01 版)で確認した値です。スナップ本体はどのブラウザでも使え、矢印とドットの疑似要素は Chrome・Edge 135 以降のみという状況なので、後半のフォールバック込み完成版を使うと全ブラウザで同じ操作感になります。
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「CSS scroll-snapで横スクロールカルーセル|JSなしの完成版」
最小構成:3行で横スクロールカルーセルにする
まず仕組みを最小のコードで確認します。親に2つ、子に2つのプロパティが要点です。
<ul class="track">
<li>1</li>
<li>2</li>
<li>3</li>
</ul>
.track {
display: flex; /* 子を横に並べる */
overflow-x: auto; /* 親をスクロール領域にする */
scroll-snap-type: x mandatory; /* 横方向に必ずスナップ */
margin: 0; padding: 0; list-style: none;
}
.track > li {
flex: 0 0 100%; /* 縮ませず、幅は親と同じ */
scroll-snap-align: start; /* 子の左端を親の左端にそろえる */
}
スナップの登場人物は次の2つです。
| 用語 | 何のことか | 指定するプロパティ |
|---|---|---|
| スクロールコンテナ | overflow が auto / scroll / hidden で、中身があふれている要素 |
scroll-snap-type、scroll-padding |
| スナップ対象 | コンテナの中にある、位置をそろえたい要素 | scroll-snap-align、scroll-snap-stop、scroll-margin |
「親に type、子に align」の組み合わせがそろって初めて動きます。 どちらか一方だけでは何も起きません。
scroll-snap-typeの値と挙動(実測)
scroll-snap-type は「軸」と「厳しさ」の2語で書きます。
| 値 | 意味 |
|---|---|
none |
スナップしない(初期値) |
x / y |
横 / 縦にスナップ |
inline / block |
文字の進む方向 / 行の積まれる方向(横書きなら inline が横) |
both |
縦横の両方 |
mandatory |
必ずどこかのスナップ位置で止まる |
proximity |
スナップ位置の近くで止まったときだけ寄せる(厳しさを省略したときの既定) |
幅300pxのコンテナに幅200pxの子を5つ並べ、scrollTo({ left: x }) を呼んだあとの scrollLeft を計測しました(Chromium 141)。
## scrollTo(x) 後の scrollLeft(コンテナ300px・アイテム200px×5)
snapなし 130→130 190→190 460→460
x mandatory + start 130→200 190→200 460→400
x proximity + start 30→0 90→0 100→0 130→200 190→200 460→400
x mandatory + center 130→150 190→150 460→550
x mandatory + end 130→100 190→100 460→500
読み取れることは3つあります。
mandatoryは、どこで止めても最寄りのスナップ位置へ移動する。 130 も 190 も 200(2枚目の左端)へ寄りました。centerとendは止まる位置そのものが変わる。centerは「子の中央=コンテナの中央」になる 150、350、550…、endは「子の右端=コンテナの右端」になる 100、300、500…です。proximityは今回の条件ではmandatoryと同じ位置に寄った。 どの距離まで寄せるかはブラウザに任されているため、カルーセルのように必ず1枚ずつ見せたい場合はmandatory、長い記事の見出しに軽く寄せたい場合はproximityが向いています。
軸の書き忘れに注意
y mandatory(軸ちがい) 130→130
mandatory のみ(軸なし) 130→130
computed scroll-snap-type (mandatory のみ): none
computed scroll-snap-type (x のみ): x
scroll-snap-type: mandatory; のように軸を省くと宣言ごと無効になり、計算値は none でした。逆に x だけなら有効で、厳しさは proximity 扱いになります。
scroll-snap-alignの値と選び方
| 値 | そろう位置 | 向いているレイアウト |
|---|---|---|
start |
子の先頭側の端をコンテナの先頭側にそろえる | 1枚が全幅のスライド、カードを左詰めで見せる一覧 |
center |
子の中央をコンテナの中央にそろえる | 左右に前後のカードを少しのぞかせるレイアウト |
end |
子の末尾側の端をコンテナの末尾側にそろえる | 右端にそろえたい一覧、チャットのように末尾を見せたい縦スクロール |
none |
その子はスナップ対象にしない(初期値) | 区切り用の要素など |
2語書くと縦と横を別々に指定できます(scroll-snap-align: start center; は「ブロック方向 start、インライン方向 center」)。
前後のカードを少し見せたいときは「子の幅を 80% 前後にして center」が簡単です。 幅が 100% のスライドなら start でも center でも止まる位置は同じになります。
scroll-paddingとscroll-marginで止まる位置をずらす
止まる位置を数十pxずらしたいときは、親側の scroll-padding か子側の scroll-margin を使います。
x mandatory + start + scroll-padding-left:40px 130→160 190→160 460→360
x mandatory + start + scroll-margin-left:20px 130→180 190→180 460→380
scroll-padding-left: 40px を付けると、止まる位置が 200 から 160 に変わりました。2枚目の左端が、コンテナの左端から 40px 内側に来るということです。scroll-margin-left: 20px は子の側から同じことを 20px ぶん行います。
| プロパティ | 書く場所 | 使いどころ |
|---|---|---|
scroll-padding |
親 | 全スライド共通の余白。固定ヘッダーの高さぶん、左右の余白ぶん |
scroll-margin |
子 | 一部の子だけずらしたいとき。ページ内リンクの着地位置の調整にも使える |
どちらも -inline / -block / -left などの個別指定があります。見た目の padding や margin とは別物で、レイアウトは変わらず、スナップ位置と scrollIntoView() の着地位置だけが変わります。
scroll-snap-stopで1枚ずつ止める
scroll-snap-stop は、勢いよくスクロールしたときに途中のスナップ位置を通り過ぎてよいかを決めます。
| 値 | 挙動 |
|---|---|
normal |
途中のスナップ位置を通り過ぎてよい(初期値) |
always |
最初に出会ったスナップ位置で必ず止まる |
子を12個に増やし、一度に 900px ぶん動かして比べました。
scrollBy(900) normal → 800
scrollBy(900) always → 200
wheel 900 normal → 800
wheel 900 always → 800
scrollBy() では always が効き、1枚先の 200 で止まりました。 一方、Playwright の mouse.wheel によるホイール入力では差が出ませんでした。タッチ端末でのフリック操作は今回の環境では試せていないため、スマートフォンでの挙動は実機で確かめてください。
always は「1枚ずつ確実に読んでほしい」場面で便利ですが、枚数が多いと目的のスライドまで何度もスワイプすることになります。枚数が多い一覧は normal のままにして、ドットや矢印で移動手段を補うと使いやすくなります。
ドット・矢印つき完成版①:CSSだけ(::scroll-button / ::scroll-marker)
Chrome・Edge 135 以降では、矢印ボタンとドットをCSSだけで生成できます。次のファイルを carousel.html として保存し、ブラウザで開くとそのまま動きます。
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>scroll-snap カルーセル</title>
<style>
*,*::before,*::after{box-sizing:border-box}
body{margin:0;font-family:system-ui,sans-serif}
.carousel{position:relative;max-width:640px;margin:40px auto;padding-inline:48px}
.carousel__track{
display:flex;gap:16px;margin:0;padding:0;list-style:none;
overflow-x:auto;
scroll-snap-type:x mandatory;
overscroll-behavior-x:contain;
scrollbar-width:none;
}
@media (prefers-reduced-motion:no-preference){
.carousel__track{scroll-behavior:smooth}
}
.carousel__slide{
flex:0 0 100%;
scroll-snap-align:center;
aspect-ratio:16/9;border-radius:12px;
display:grid;place-items:center;
font-size:2rem;color:#fff;background:#2563eb;
}
.carousel__slide:nth-child(even){background:#0f766e}
.carousel__track:focus-visible{outline:3px solid #f59e0b;outline-offset:4px}
/* ---- 矢印とドット(CSSだけで生成) ---- */
.carousel__track{scroll-marker-group:after}
.carousel__track::scroll-button(inline-start){content:"‹" / "前のスライド"}
.carousel__track::scroll-button(inline-end){content:"›" / "次のスライド"}
.carousel__track::scroll-button(*){
position:absolute;top:calc(50% - 34px);
width:40px;height:40px;border:0;border-radius:50%;
font-size:24px;line-height:1;color:#fff;background:#1e293b;cursor:pointer;
}
.carousel__track::scroll-button(inline-start){left:0}
.carousel__track::scroll-button(inline-end){right:0}
.carousel__track::scroll-button(*):disabled{opacity:.3;cursor:default}
.carousel__track::scroll-button(*):focus-visible{outline:3px solid #f59e0b;outline-offset:2px}
.carousel__track::scroll-marker-group{
display:flex;justify-content:center;gap:12px;padding-top:12px;height:28px;
}
.carousel__slide::scroll-marker{
content:"" / attr(data-label);width:16px;height:16px;border-radius:50%;
background:#cbd5e1;
}
.carousel__slide::scroll-marker:target-current{background:#2563eb}
.carousel__slide::scroll-marker:focus-visible{outline:3px solid #f59e0b;outline-offset:2px}
</style>
</head>
<body>
<section class="carousel" aria-label="おすすめ記事">
<ul class="carousel__track">
<li class="carousel__slide" data-label="スライド 1">1</li>
<li class="carousel__slide" data-label="スライド 2">2</li>
<li class="carousel__slide" data-label="スライド 3">3</li>
<li class="carousel__slide" data-label="スライド 4">4</li>
<li class="carousel__slide" data-label="スライド 5">5</li>
</ul>
</section>
</body>
</html>
新しい疑似要素とプロパティの役割
| 書き方 | 役割 |
|---|---|
::scroll-button(inline-start) / (inline-end) |
スクロール領域に付く「前へ」「次へ」ボタン。content を書くと生成される |
::scroll-button(*) |
生成したボタン全部にまとめてスタイルを当てる |
:disabled |
これ以上進めない側のボタンに自動で付く状態 |
scroll-marker-group: after(または before) |
ドットを入れる箱を、スクロール領域の後ろ(前)に作る |
::scroll-marker-group |
その箱。display: flex などで並べ方を決める |
::scroll-marker |
子1つにつき1個できるドット。content を書くと生成される |
:target-current |
今表示中の子に対応するドットに付く状態 |
content: "›" / "次のスライド" |
スラッシュの後ろは読み上げ用の代替テキスト |
::scroll-button() の引数には left / right / up / down と、書字方向に合わせた inline-start / inline-end / block-start / block-end があります。MDN によると、仕様にある next / prev はまだどのブラウザにも入っていません。
動かして確認した結果
Playwright で矢印とドットをクリックし、スクロール位置と今のドット(●)を記録しました。
Chromium 141.0.7390.37
supports scroll-button true scroll-marker true smg true
初期 scrollLeft 0 ●○○○○
次へ1回 scrollLeft 560 ○●○○○
次へ2回 scrollLeft 1120 ○○●○○
前へ1回 scrollLeft 560 ○●○○○
5個目のドット scrollLeft 2240 ○○○○●
末尾で次へ scrollLeft 2240
スライド幅 544px+すき間 16px=560px ずつ進み、ドットも追従しています。末尾では「次へ」が自動で :disabled になり、押しても位置は変わりません。 状態管理のJavaScriptは1行も書いていません。
矢印の位置は position: absolute で決められる
::scroll-button() はスクロール領域の「隣」に置かれる扱いなので、外側の .carousel に position: relative を付けておけば、position: absolute と left / right で好きな位置に置けます。完成版では上下中央から、ドットの高さぶん(28px)の半分と、ボタンの高さの半分を引いた位置に置いています。
ドット・矢印つき完成版②:全ブラウザ対応(フォールバック込み)
Firefox と Safari は 2026年10月時点で ::scroll-button() と ::scroll-marker に未対応です。そこで、対応ブラウザではCSSだけ、未対応ブラウザでは本物の <button> と最小限のJavaScriptで同じ見た目を出す版を用意しました。combined.html として保存すればそのまま動きます。
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>scroll-snap カルーセル(フォールバック込み)</title>
<style>
*,*::before,*::after{box-sizing:border-box}
body{margin:0;font-family:system-ui,sans-serif}
.carousel{position:relative;max-width:640px;margin:40px auto;padding-inline:48px}
.carousel__track{
display:flex;gap:16px;margin:0;padding:0;list-style:none;
overflow-x:auto;
scroll-snap-type:x mandatory;
overscroll-behavior-x:contain;
scrollbar-width:none;
}
@media (prefers-reduced-motion:no-preference){
.carousel__track{scroll-behavior:smooth}
}
.carousel__slide{
flex:0 0 100%;
scroll-snap-align:center;
aspect-ratio:16/9;border-radius:12px;
display:grid;place-items:center;
font-size:2rem;color:#fff;background:#2563eb;
}
.carousel__slide:nth-child(even){background:#0f766e}
.carousel__track:focus-visible{outline:3px solid #f59e0b;outline-offset:4px}
.carousel__arrow{
position:absolute;top:calc(50% - 34px);
width:40px;height:40px;border:0;border-radius:50%;
font-size:24px;line-height:1;color:#fff;background:#1e293b;cursor:pointer;
}
.carousel__arrow--prev{left:0}
.carousel__arrow--next{right:0}
.carousel__arrow:disabled{opacity:.3;cursor:default}
.carousel__dots{display:flex;justify-content:center;gap:12px;padding-top:12px;height:28px}
.carousel__dot{width:16px;height:16px;padding:0;border:0;border-radius:50%;background:#cbd5e1;cursor:pointer}
.carousel__dot[aria-current="true"]{background:#2563eb}
.carousel__arrow:focus-visible,.carousel__dot:focus-visible{outline:3px solid #f59e0b;outline-offset:2px}
[hidden]{display:none !important}
/* ---- 対応ブラウザ:矢印とドットをCSSだけで生成 ---- */
@supports selector(::scroll-marker) {
.carousel__track{scroll-marker-group:after}
.carousel__track::scroll-button(inline-start){content:"‹" / "前のスライド"}
.carousel__track::scroll-button(inline-end){content:"›" / "次のスライド"}
.carousel__track::scroll-button(*){
position:absolute;top:calc(50% - 34px);
width:40px;height:40px;border:0;border-radius:50%;
font-size:24px;line-height:1;color:#fff;background:#1e293b;cursor:pointer;
}
.carousel__track::scroll-button(inline-start){left:0}
.carousel__track::scroll-button(inline-end){right:0}
.carousel__track::scroll-button(*):disabled{opacity:.3;cursor:default}
.carousel__track::scroll-button(*):focus-visible{outline:3px solid #f59e0b;outline-offset:2px}
.carousel__track::scroll-marker-group{
display:flex;justify-content:center;gap:12px;padding-top:12px;height:28px;
}
.carousel__slide::scroll-marker{
content:"" / attr(data-label);width:16px;height:16px;border-radius:50%;
background:#cbd5e1;
}
.carousel__slide::scroll-marker:target-current{background:#2563eb}
.carousel__slide::scroll-marker:focus-visible{outline:3px solid #f59e0b;outline-offset:2px}
}
</style>
</head>
<body>
<section class="carousel" aria-roledescription="カルーセル" aria-label="おすすめ記事">
<button class="carousel__arrow carousel__arrow--prev" type="button" hidden aria-label="前のスライド">‹</button>
<ul class="carousel__track">
<li class="carousel__slide" data-label="スライド 1">1</li>
<li class="carousel__slide" data-label="スライド 2">2</li>
<li class="carousel__slide" data-label="スライド 3">3</li>
<li class="carousel__slide" data-label="スライド 4">4</li>
<li class="carousel__slide" data-label="スライド 5">5</li>
</ul>
<button class="carousel__arrow carousel__arrow--next" type="button" hidden aria-label="次のスライド">›</button>
<div class="carousel__dots" hidden></div>
</section>
<script>
// ::scroll-marker に対応したブラウザでは何もしない(CSSだけで動く)
if (!CSS.supports('selector(::scroll-marker)')) {
document.querySelectorAll('.carousel').forEach(initCarousel);
}
function initCarousel(root) {
const track = root.querySelector('.carousel__track');
const slides = [...track.children];
const prev = root.querySelector('.carousel__arrow--prev');
const next = root.querySelector('.carousel__arrow--next');
const dotsBox = root.querySelector('.carousel__dots');
let current = 0;
prev.hidden = next.hidden = dotsBox.hidden = false;
track.tabIndex = 0;
const go = (i) => slides[Math.max(0, Math.min(i, slides.length - 1))]
.scrollIntoView({ inline: 'center', block: 'nearest' });
const dots = slides.map((_, i) => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'carousel__dot';
b.setAttribute('aria-label', `スライド ${i + 1}`);
b.addEventListener('click', () => go(i));
dotsBox.append(b);
return b;
});
const update = () => {
dots.forEach((d, i) => d.setAttribute('aria-current', String(i === current)));
prev.disabled = current === 0;
next.disabled = current === slides.length - 1;
};
prev.addEventListener('click', () => go(current - 1));
next.addEventListener('click', () => go(current + 1));
const io = new IntersectionObserver((entries) => {
for (const e of entries) {
if (e.isIntersecting) { current = slides.indexOf(e.target); update(); }
}
}, { root: track, threshold: 0.6 });
slides.forEach((s) => io.observe(s));
update();
}
</script>
</body>
</html>
切り替えの仕組み
| 部分 | 対応ブラウザ | 未対応ブラウザ |
|---|---|---|
| CSS | @supports selector(::scroll-marker) { ... } の中が有効になり、疑似要素の矢印とドットが出る |
@supports の中は無視される |
| HTMLのボタン | hidden のまま表示されない |
JavaScript が hidden を外して表示する |
| JavaScript | CSS.supports('selector(::scroll-marker)') が true なので何もしない |
ドットを生成し、クリックと現在位置の更新を担当する |
JavaScript がしていることは3つだけです。
- 移動:
scrollIntoView({ inline: 'center', block: 'nearest' })で目的のスライドへ動かす(止まる位置はCSSのスナップが整える) - 現在位置の検出:
IntersectionObserverで「6割以上見えているスライド」を今のスライドとする - 状態の反映:ドットの
aria-currentと、端での矢印のdisabledを更新する
scroll イベントで座標を計算する方法より、IntersectionObserver のほうが呼ばれる回数が少なく、スライド幅が変わっても書き直しが要りません。
両方の経路を動かして確認した結果
同じ combined.html を、通常の Chromium 141 と、--disable-blink-features=CSSPseudoScrollButtons,CSSPseudoScrollMarkers,CSSScrollMarkerGroup を付けて疑似要素を無効にした Chromium 141 の両方で開きました。
::scroll-marker 対応: true | 実ボタン表示数: 0 | tabindex: null
初期 scrollLeft 0 ●○○○○
次へ scrollLeft 560 ○●○○○
次へ scrollLeft 1120 ○○●○○
前へ scrollLeft 560 ○●○○○
ドット5 scrollLeft 2240 ○○○○●
errors 0
::scroll-marker 対応: false | 実ボタン表示数: 7 | tabindex: 0
初期 scrollLeft 0 ●○○○○
次へ scrollLeft 560 ○●○○○
次へ scrollLeft 1120 ○○●○○
前へ scrollLeft 560 ○●○○○
ドット5 scrollLeft 2240 ○○○○●
errors 0
どちらの経路でも、スクロール位置とドットの状態が同じ結果になりました。 未対応側では矢印2個+ドット5個の計7個のボタンが表示されています。なお、Firefox と Safari の実機では動かしていません。使っているAPI(IntersectionObserver、scrollIntoView のオプション、CSS.supports、@supports selector())は各ブラウザの現行版で使えるものに絞っています。
スナップが効かない原因と直し方(再現結果つき)
「書いたのに止まらない」「そもそも横にスクロールしない」というときは、たいてい次のどれかに当てはまります。それぞれ再現して、scrollTo({ left: 130 }) を呼んだあとの値を計測しました。
## 効かない原因の再現
A: overflow 指定なし {"clientWidth":300,"scrollWidth":1000,"itemWidth":200,"scrollLeft":0,"overflowX":"visible","docScrollWidth":1000}
B: width:200px だけ(flex-shrink 既定) {"clientWidth":300,"scrollWidth":300,"itemWidth":60,"scrollLeft":0,"overflowX":"auto","docScrollWidth":800}
B改: flex:0 0 200px {"clientWidth":300,"scrollWidth":1000,"itemWidth":200,"scrollLeft":200,"overflowX":"auto","docScrollWidth":800}
C: 幅指定なし(中身は短い文字) {"clientWidth":300,"scrollWidth":300,"itemWidth":12,"scrollLeft":0,"overflowX":"auto","docScrollWidth":800}
D: overflow-x:hidden {"clientWidth":300,"scrollWidth":1000,"itemWidth":200,"scrollLeft":200,"overflowX":"hidden","docScrollWidth":800}
E: display:flex なし(ブロック縦並び) {"clientWidth":300,"scrollWidth":300,"itemWidth":200,"scrollLeft":0,"overflowX":"auto","docScrollWidth":800}
| 症状 | 原因 | 計測でわかったこと | 直し方 |
|---|---|---|---|
| A: ページ全体が横にスクロールする | 親に overflow がない |
overflowX が visible。あふれた子がページ幅を 1000px に広げた |
親に overflow-x: auto |
| B: 子が細くなってスクロールしない | width だけ書き、flex-shrink が既定の 1 |
200px のはずの子が 60px に縮み、scrollWidth が親と同じ 300 |
子に flex: 0 0 200px(または flex-shrink: 0) |
| C: 子が中身の幅しかない | 子の幅を指定していない | 子の幅が 12px。あふれないのでスクロール領域にならない | 子に flex: 0 0 80% など幅を明示 |
| E: 子が縦に並ぶ | 親が display: flex でない |
縦に積まれて横にあふれない | 親に display: flex(または grid で grid-auto-flow: column) |
| 止まらない | 軸がちがう/軸を書いていない | y mandatory や mandatory だけでは 130→130 のまま |
scroll-snap-type: x mandatory |
| 止まらない | scroll-snap-align を書いた要素と親の間に、別のスクロールコンテナがある |
間の div が overflow: hidden だと 130→130。overflow が既定なら孫でも 130→200 |
間の要素の overflow を外すか、type をその要素に書く |
D の overflow-x: hidden は、スクロールバーも操作手段も出ないものの、スクロールコンテナにはなります。 scrollTo() では 200 にスナップしました。矢印やドットだけで動かすカルーセルにする場合の選択肢ですが、タッチやキーボードで動かせなくなるため、基本は auto をおすすめします。
開発者ツールでの確認手順
- 親を選び、Computed(計算済み)タブで
overflow-xがautoかscrollになっているか見る - コンソールで
el.scrollWidth > el.clientWidthがtrueか確かめる(falseなら子が縮んでいるか、幅が足りない) - 子の幅が意図どおりか見る(意図より細ければ
flex-shrink) - 親の
scroll-snap-typeの計算値がnoneになっていないか見る(軸の書き忘れ) - 子の
scroll-snap-alignの計算値がnoneでないか、子と親の間にoverflow付きの要素がないかを見る
キーボード操作とアクセシビリティ
カルーセルは、マウスやタッチ以外でも操作できるようにしておくと、より多くの人に届きます。完成版で確認した内容をまとめます。
キーボード操作の確認結果(Chromium 141)
完成版①を開いた直後の状態からキーを押し、フォーカスの枠線が出た場所とスクロール位置を記録しました。
Tab → 枠線: ドット1 | scrollLeft 0
ArrowRight → 枠線: ドット2 | scrollLeft 560
ArrowRight → 枠線: ドット3 | scrollLeft 1120
ArrowLeft → 枠線: ドット2 | scrollLeft 560
| 開いた直後からの操作 | フォーカスの位置 | 結果 |
|---|---|---|
| Tab を1回 | ドットのグループ(1個目) | 位置は 0 のまま |
| 続けて → / ← | ドット2、ドット3… | ドットの移動に合わせて 560、1120 とスクロール。1個目で ← を押すと5個目へ回り込んだ |
| Tab を2回 | 有効な矢印ボタン(先頭では「次へ」) | Enter で 560、Space でさらに 1120 へ |
| Tab を3回 | スクロール領域そのもの | → キーで1枚ずつ進む |
ドットはグループ全体でTab 1回ぶん、中の移動は矢印キーという、タブ切り替えと同じ操作体系です。スクロール領域自体にも Tab でフォーカスが入り、→ キーで1枚ずつスナップしました(最小構成でも 0 → 200 → 400 と進むことを確認)。スクロール領域に Tab でフォーカスが入るかどうかはブラウザによって異なるため、完成版②の未対応側では JavaScript で tabIndex = 0 を付けています。
なお、ドットを矢印キーで動かした直後に Tab を押した場合は、今回の計測では枠線がドットに残りました。フォーカスの細かな順序は新しい機能ゆえに今後変わる可能性があるので、公開前に手元のブラウザでひととおりキー操作を試しておくと安心です。
支援技術にどう伝わるか
Chromium のアクセシビリティツリーを取得した結果です。
AX region "おすすめ記事"
AX button "前のスライド" disabled=true
AX button "次のスライド"
AX list ""
AX tablist ""
AX tab "スライド 1" selected=true
AX tab "スライド 2" selected=false
AX tab "スライド 3" selected=false
AX tab "スライド 4" selected=false
AX tab "スライド 5" selected=false
疑似要素の矢印は button、ドットは tablist と tab として公開され、先頭では「前のスライド」が disabled になっています。content のスラッシュ以降に書いた代替テキストが名前になっていました。ドットの名前は content: "" / attr(data-label) で、HTML側の data-label から取っています。
チェックリスト
| 項目 | 完成版での対応 |
|---|---|
| カルーセル全体に名前がある | <section aria-label="おすすめ記事"> |
| 矢印とドットに名前がある | content: "›" / "次のスライド"、未対応側は aria-label |
| 今のスライドが伝わる | 対応側は tab の selected、未対応側は aria-current="true" |
| フォーカスが見える | :focus-visible で 3px の枠線 |
| 動きを減らす設定を尊重する | scroll-behavior: smooth を prefers-reduced-motion: no-preference の中だけに書く |
| 押しやすい大きさ | 矢印 40px。ドットは 16px+すき間 12px(WCAG 2.2 の達成基準 2.5.8 は 24px 四方が目安で、間隔で満たす方法もある) |
| 自動で動かさない | 自動再生は入れていない。入れる場合は停止ボタンを用意する |
| リストとして読める | <ul> と <li> を使い、枚数が伝わるようにする |
仕上げに使えるコピペ集
前後のカードをのぞかせる
.carousel__track { scroll-padding-inline: 24px; padding-inline: 24px; }
.carousel__slide { flex: 0 0 80%; scroll-snap-align: center; }
画面幅で見せる枚数を変える
.carousel__slide { flex: 0 0 100%; scroll-snap-align: start; }
@media (min-width: 640px) { .carousel__slide { flex-basis: calc((100% - 16px) / 2); } }
@media (min-width: 1024px) { .carousel__slide { flex-basis: calc((100% - 32px) / 3); } }
gap: 16px のぶんを引いておくと、ちょうど2枚・3枚が収まります。
スクロールバーを隠す(広い範囲のブラウザ向け)
.carousel__track { scrollbar-width: none; } /* Chrome 121+ / Firefox / Safari 18.2+ */
.carousel__track::-webkit-scrollbar { display: none; } /* それより前の Safari・Chrome 向け */
縦方向の全画面スナップ
.pages { height: 100dvh; overflow-y: auto; scroll-snap-type: y mandatory; }
.pages > section { height: 100dvh; scroll-snap-align: start; }
ドットを番号つきにする
.carousel__slide::scroll-marker {
content: attr(data-no) / attr(data-label);
width: 28px; height: 28px; display: grid; place-items: center;
font-size: 12px; color: #1e293b;
}
HTML側に data-no="1" のように番号を持たせる書き方です。この節のスニペットは完成版と同じプロパティの組み合わせで、個別の計測は行っていません。
方式の選び方
| 条件 | おすすめ |
|---|---|
| スワイプで見られれば十分。ドットや矢印は不要 | 最小構成(スナップのみ)。全ブラウザで動く |
| 社内ツールなど、利用ブラウザが Chrome・Edge に決まっている | 完成版①(CSSだけ) |
| 一般公開のサイトで、矢印とドットを全員に見せたい | 完成版②(フォールバック込み) |
| 自動再生、無限ループ、サムネイル連動など多機能が必要 | スナップを土台に JavaScript を足すか、ライブラリの利用を検討 |
スナップ本体をCSSに任せておくと、JavaScript側は「どこへ動かすか」だけを考えればよくなります。 慣性スクロールや止まる位置の計算をブラウザが受け持つため、タッチ操作の感触も自然です。
よくある質問
Q. スマートフォンでも動きますか?
A. scroll-snap-type と scroll-snap-align は iOS Safari 11 以降、Android の Chrome でも使えます。完成版②は、疑似要素に未対応の iOS Safari では JavaScript 側の矢印とドットに切り替わる作りです。今回の動作確認は Chromium 141 で行ったもので、実機のタッチ操作は確認していません。
Q. 最後のスライドから最初へ戻る無限ループはCSSだけでできますか? A. スナップだけでは実現できません。端で矢印が無効になる今の挙動が標準です。ループが必要なら、JavaScript で先頭と末尾にスライドの複製を置く方法などを検討します。
Q. scroll-behavior: smooth は必ず入れるべきですか?
A. 入れなくても動きます。入れる場合は、OSで「視差効果を減らす」などを設定している人に配慮して、@media (prefers-reduced-motion: no-preference) の中に書くと安心です。
Q. スナップした瞬間をJavaScriptで知る方法はありますか?
A. scrollsnapchange イベントがありますが、Chrome・Edge 129 以降のみです。全ブラウザで動かすなら、この記事の完成版②のように IntersectionObserver を使うか、scrollend イベント(Chrome 114、Firefox 109、Safari 26.2 以降)で位置を調べます。
まとめ
- 親に
overflow-x: autoとscroll-snap-type: x mandatory、子にscroll-snap-alignの組み合わせで横スクロールカルーセルになる - 子には
flex: 0 0 幅を書く。widthだけだと 200px のはずが 60px に縮み、スクロールしなかった - 軸を省いた
scroll-snap-type: mandatoryは無効で、計算値はnoneになる scroll-paddingは親、scroll-marginは子に書いて止まる位置をずらす(40px 指定で 200 → 160 に変化)::scroll-button()と::scroll-markerは Chrome・Edge 135 以降。矢印はbutton、ドットはtabとして支援技術に伝わる- 未対応ブラウザには
@supports selector(::scroll-marker)と最小JSで同じ見た目を出せる。両経路で同じ結果を確認した :focus-visible・代替テキスト・prefers-reduced-motionを入れて、キーボードでも使えるカルーセルにする
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「CSS scroll-snapで横スクロールカルーセル|JSなしの完成版」
株式会社RJCでは、CSS scroll-snapのようなブラウザ標準の機能を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。