テクニック

CSSのscroll-snapでカルーセル実装|JS不要の横スクロールUIの作り方

この記事の対象: 横スクロールのカードUIをライブラリなしで実装したい制作者
読了時間: 約12分

カルーセルを作るとき、反射的にSwiperを読み込んでいませんか。全画面のヒーロースライダーならライブラリの価値はありますが、「カードが横に並んでスクロールする」だけのUIなら、CSSのscroll-snapで足ります。JSは0行、追加のリクエストも0件。

この記事では、そのまま動くHTMLとCSSを載せます。スナップの効かせ方、スクロールバーの隠し方、端の余白の処理まで、実際にハマる順番で並べました。矢印ボタンを足したい場合の最小限のJS(20行程度)も最後に置いています。

コピーして手元のファイルに貼れば、その場で動きます。


前提と動作環境

前提と動作環境

必要なもの

素のHTML/CSSファイルが1つあれば動きます。ビルドツールもnpmも不要です。

環境 置き場所
静的HTML 任意の.htmlとCSSファイル
WordPress(ブロックテーマ) カスタムHTMLブロック + theme.jsonと同階層のstyle.css
WordPress(クラシックテーマ) 子テーマのstyle.css + テンプレートPHP
Shopify(Online Store 2.0) sections/配下の.liquid{% stylesheet %}タグ

Shopifyのセクション化まで踏み込む場合は、こちらに手順をまとめてあります。

ブラウザ対応

scroll-snap-typescroll-snap-alignは、Chrome・Edge・Firefox・Safariの現行版すべてで動きます。この2つのプロパティに関しては、対応状況を気にする必要はほぼありません。

一方で、後半で使うscroll-behavior: smoothscrollbar-width: noneは、対応が入った時期が違います。実装前にcaniuse.comでcss-scroll-snapcss-scrollbarと検索して、案件のサポート範囲と突き合わせてください。IE11を含む要件がある場合は、この記事の手法は使えません。素直にライブラリを検討してください。


scroll-snapの最小構成

scroll-snapの最小構成

まず動くものを作ります。以下をそのまま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>
.carousel {
  display: flex;
  gap: 16px;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
}

.carousel__item {
  flex: 0 0 280px;
  scroll-snap-align: start;
  background: #f2f2f2;
  border-radius: 8px;
  padding: 24px;
  box-sizing: border-box;
}
</style>
</head>
<body>

<div class="carousel">
  <div class="carousel__item">カード1</div>
  <div class="carousel__item">カード2</div>
  <div class="carousel__item">カード3</div>
  <div class="carousel__item">カード4</div>
  <div class="carousel__item">カード5</div>
</div>

</body>
</html>

これで横スクロールし、指を離すとカードの左端でピタッと止まります。カルーセルの本体は、親のscroll-snap-typeと子のscroll-snap-alignの2行だけです。残りはすべて見た目の指定なので、消しても動きます。

scroll-snap-type は親に書く

scroll-snap-type: x mandatoryスクロールする側の要素、つまりoverflow-x: autoを持つ親に書きます。子に書いても何も起きません。ここを間違えるのが最も多いつまずきです。

値は方向とスナップの強さの2つで構成されます。

意味
x 横方向にスナップする(縦ならy、両方ならboth
mandatory 必ずどこかのスナップ位置で止まる
proximity スナップ位置が近いときだけ吸い付く

mandatoryproximityの使い分けには実用上の基準があります。アイテムがコンテナに収まりきらない大きさになりうる場合はproximitymandatoryは「必ず止まる」ので、コンテナより大きい要素があると途中でスクロールが止められず、内容が読めなくなります。カードUIのように各アイテムがビューポートに収まる前提ならmandatoryで問題ありません。

scroll-snap-align は子に書く

scroll-snap-alignは各アイテムに書きます。どこを基準に吸い付くかの指定です。

  • start — アイテムの先頭(横なら左端)をコンテナの先頭に合わせる
  • center — アイテムの中央をコンテナの中央に合わせる
  • end — アイテムの末尾をコンテナの末尾に合わせる

カード型の一覧ならstart。1枚ずつ大きく見せるヒーロー型ならcenterが自然です。この記事ではstartで進めます。

変更すべき箇所

コピーしたあと、以下を自分の設計に合わせてください。

箇所 現在の値 何を決めるか
.carousel / .carousel__item プロジェクトのクラス命名規則に合わせる
flex: 0 0 280px 280px カード1枚の幅
gap: 16px 16px カード間の余白
background / border-radius グレー 見た目。実装時は自前のスタイルに差し替え

flex: 0 0 280px0 0は「伸びない・縮まない」という指定です。ここを1 1にするとカードが親の幅に収まろうとして横スクロールが発生しなくなります。必ず0 0にしてください。


スクロールバーを隠す

スクロールバーを隠す

初期状態ではコンテナの下にスクロールバーが出ます。macOSでは普段見えませんが、Windowsでは常時表示される設定が既定なので、カードの下に灰色のバーが居座ります。デザイン的に許容できないことがほとんどなので、隠します。

.carousel {
  display: flex;
  gap: 16px;
  overflow-x: auto;
  scroll-snap-type: x mandatory;

  /* スクロールバーを隠す */
  scrollbar-width: none;        /* Firefox / 標準仕様 */
  -ms-overflow-style: none;     /* 古いEdge・IE */
}

.carousel::-webkit-scrollbar {
  display: none;                /* Chrome / Safari / 新Edge */
}

3つ書くのは、実装の経緯が違うためです。scrollbar-widthが標準仕様で、::-webkit-scrollbarはWebKit由来の独自実装。Chrome系はscrollbar-widthにも対応済みですが、少し前のバージョンを含めてカバーするなら両方書いておくのが安全です。ファイルサイズへの影響は誤差の範囲なので、消す理由がありません。

隠したなら、代わりの手がかりを置く

スクロールバーは「まだ右に続いている」という情報を伝えていました。消したなら、別の方法で伝える必要があります。これをやらないと、ユーザーは見えている枚数がすべてだと思って離脱します。

有効なのは以下です。

  • 右端のカードを画面から半分はみ出させる — 最も確実。後述の余白処理で自然に実現できる
  • 右端にグラデーションのフェードを重ねる — 続きがあることを視覚的に示す
  • 矢印ボタンを置く — 記事の後半で実装します

一番安いのは1つ目です。カードの幅を「画面幅ちょうど」ではなく「画面幅の80%」のように設定すると、次のカードが常に少し覗きます。

.carousel__item {
  /* 画面いっぱいのカードを避け、次が覗く幅にする */
  flex: 0 0 clamp(240px, 78vw, 320px);
  scroll-snap-align: start;
}

clamp()は「最小値・推奨値・最大値」の3つを取ります。この例だとスマホでは画面の78%、大きい画面では320pxで頭打ちになります。カードが1枚ずつ大きく見えすぎるのも、細かくなりすぎるのも防げる書き方です。

2つ目のグラデーションを足すなら、コンテナの外側にラッパーを置いて重ねます。カルーセル自身に::afterを重ねるとスクロールと一緒に流れてしまうので、位置を固定できる親側に置くのがポイントです。

.carousel-wrap {
  position: relative;
}

.carousel-wrap::after {
  content: "";
  position: absolute;
  inset-block: 0;
  right: 0;
  width: 48px;
  background: linear-gradient(to left, #fff, transparent);
  pointer-events: none;   /* クリックとスワイプを透過させる */
}

pointer-events: noneを落とすと、右端でスワイプが効かなくなります。重ねる系の演出では必ずセットで書きます。


端の余白を正しく処理する

端の余白を正しく処理する

ここが最もつまずく箇所です。

多くのサイトでは、カルーセルだけ画面幅いっぱいに広げて、左端はコンテンツの左揃えに合わせたい、という要件になります。素直にpadding-leftを書くと、スクロールして右端まで行ったときに右側の余白が消えることがあります。Flexコンテナのpaddingがスクロール領域の末尾に反映されない挙動が、ブラウザによって残っているためです。

端の余白でよく起きる崩れ

scroll-padding + 疑似要素

  • 左端がコンテンツ幅に揃う
  • 右端まで送っても余白が残る
  • スナップ位置も余白ぶんズレない

paddingだけで組んだ場合

  • 左端は揃う
  • 右端の余白が消えて画面に貼り付く
  • スナップ位置が余白のぶんズレる

右端で余白が消えるかどうかは、実機で最後までスクロールしないと気づきません。実装したら必ず右端まで送って確認してください。

解決策

余白は「padding」と「scroll-padding」の2つで扱います。役割が違います。

.carousel {
  display: flex;
  gap: 16px;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scrollbar-width: none;
  -ms-overflow-style: none;

  /* 左右の余白。ここを自分のコンテンツ幅に合わせる */
  --edge: max(16px, calc((100vw - 1120px) / 2));
  padding-inline: var(--edge);

  /* スナップ位置を余白のぶんずらす */
  scroll-padding-inline: var(--edge);
}

.carousel::-webkit-scrollbar {
  display: none;
}

/* 右端の余白を確保する(padding-rightだけでは消えるため) */
.carousel::after {
  content: "";
  flex: 0 0 1px;
}

--edgeが余白の値です。1120pxの部分を、自分のサイトのコンテンツ幅に置き換えてください。max()で囲んでいるので、画面が狭いときは16pxまで縮み、広いときはコンテンツ左端に揃います。

paddingはコンテナの内側の余白、scroll-paddingはスナップ位置の基準線をどれだけ内側にずらすかの指定です。前者だけ書くと、スナップした位置がpaddingのぶん左にズレて、カードの左端が画面外に出ます。paddingを書いたら必ずscroll-paddingもセットで書く、値も同じにする、と覚えてください。

疑似要素の::afterは、右paddingがスクロール末尾に効かない挙動への保険です。Flexアイテムとして1px分の実体を末尾に置くことで、スクロール領域の終端が確実に伸びます。ここをflex: 0 0 0にすると幅0のアイテムになり、環境によっては効きません。1pxにしておくのが確実です。

なお、--edgeをJS側から参照したい場面が出てくるので、:rootではなく.carouselに持たせています。後半の矢印ボタンでカード幅を実測する設計と揃えておくと、値の管理場所が1箇所に収まります。


実務で足したい仕上げ

実務で足したい仕上げ

スクロールの慣性を調整する

.carousel {
  scroll-behavior: smooth;
  overscroll-behavior-x: contain;
}

scroll-behavior: smoothは、後述のJSやアンカーリンクによるスクロールをアニメーションさせます。指でのスワイプには影響しません。

overscroll-behavior-x: containは重要です。これを書かないと、カルーセルを右端までスクロールしたあとさらにスワイプしたときに、ページ全体が横に動いたり、ブラウザの「戻る」ジェスチャーが発動したりします。スマホで最も苦情が出る挙動なので、必ず入れてください。

アニメーションを減らす設定を尊重する

@media (prefers-reduced-motion: reduce) {
  .carousel {
    scroll-behavior: auto;
  }
}

OSで「視差効果を減らす」を有効にしているユーザーには、スムーススクロールを切ります。3行で済むので入れておく価値があります。

キーボード操作を通す

カルーセルにフォーカスが当たったとき矢印キーでスクロールできるよう、tabindexを付けます。

<div class="carousel" tabindex="0" role="group" aria-label="おすすめ記事">
  <div class="carousel__item">カード1</div>
</div>

tabindex="0"でTabキーの到達先になり、左右キーでスクロールします。aria-labelは中身に合わせて書き換えてください。フォーカスリングをoutline: noneで消さないこと。 消すとキーボードユーザーが現在位置を見失います。デザイン上どうしても既定のリングが合わない場合は、消すのではなく:focus-visibleで自前のリングに置き換えます。

.carousel:focus-visible {
  outline: 2px solid #333;
  outline-offset: 2px;
}

スナップの効きすぎを止める

scroll-snap-stopで、一気に複数枚を送り飛ばすかどうかを制御できます。

.carousel__item {
  scroll-snap-align: start;
  scroll-snap-stop: normal;   /* 既定値。勢いよくスワイプすると数枚まとめて送る */
}

alwaysにすると1回のスワイプで必ず1枚だけ進みます。商品詳細のように「1枚ずつ確実に見せたい」場合はalways、記事一覧のように「速く流し見したい」場合はnormal。カードUIならnormalのままで構いません。

画像を入れるならレイアウトシフトを潰す

カード内にサムネイルを置く構成が大半ですが、widthheightを書かない画像を横並びにすると、読み込みのたびにカードの高さが動きます。カルーセルは初期表示で画面内に入ることが多いので、ここがそのままCLSに乗ります。

<article class="carousel__item">
  <img src="thumb.jpg" alt="" width="640" height="360" loading="lazy" decoding="async">
  <h3 class="carousel__title">カードのタイトル</h3>
</article>
.carousel__item img {
  width: 100%;
  height: auto;
  aspect-ratio: 16 / 9;
  object-fit: cover;
  display: block;
}

width/height属性は「実寸」ではなく「縦横比の宣言」として効きます。CSS側でaspect-ratioも指定しておけば、画像の実比率が違っても枠が動きません。loading="lazy"は、ファーストビューに入るカルーセルの1枚目だけ外すのが無難です。


完成形のコード

完成形のコード

ここまでを1つにまとめました。これが実務でそのまま使える形です。

<div class="carousel" tabindex="0" role="group" aria-label="おすすめ記事">
  <article class="carousel__item">
    <h3 class="carousel__title">カード1のタイトル</h3>
    <p class="carousel__text">説明テキストが入ります。</p>
  </article>
  <article class="carousel__item">
    <h3 class="carousel__title">カード2のタイトル</h3>
    <p class="carousel__text">説明テキストが入ります。</p>
  </article>
  <article class="carousel__item">
    <h3 class="carousel__title">カード3のタイトル</h3>
    <p class="carousel__text">説明テキストが入ります。</p>
  </article>
  <article class="carousel__item">
    <h3 class="carousel__title">カード4のタイトル</h3>
    <p class="carousel__text">説明テキストが入ります。</p>
  </article>
  <article class="carousel__item">
    <h3 class="carousel__title">カード5のタイトル</h3>
    <p class="carousel__text">説明テキストが入ります。</p>
  </article>
</div>
.carousel {
  /* ▼ ここを自サイトのコンテンツ幅に変更 */
  --edge: max(16px, calc((100vw - 1120px) / 2));
  --gap: 16px;
  --card: clamp(240px, 78vw, 320px);

  display: flex;
  gap: var(--gap);
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-behavior: smooth;
  overscroll-behavior-x: contain;
  padding-inline: var(--edge);
  scroll-padding-inline: var(--edge);
  padding-block: 8px;

  scrollbar-width: none;
  -ms-overflow-style: none;
}

.carousel::-webkit-scrollbar {
  display: none;
}

.carousel::after {
  content: "";
  flex: 0 0 1px;
}

.carousel:focus-visible {
  outline: 2px solid #333;
  outline-offset: 2px;
}

.carousel__item {
  flex: 0 0 var(--card);
  scroll-snap-align: start;
  scroll-snap-stop: normal;
  box-sizing: border-box;

  /* ▼ 見た目は自サイトのスタイルに差し替え */
  padding: 20px;
  border: 1px solid #e0e0e0;
  border-radius: 12px;
  background: #fff;
}

.carousel__title {
  margin: 0 0 8px;
  font-size: 1rem;
  line-height: 1.5;
}

.carousel__text {
  margin: 0;
  font-size: 0.875rem;
  line-height: 1.7;
  color: #555;
}

/* PCではカード幅を固定に切り替える */
@media (min-width: 768px) {
  .carousel {
    --card: 300px;
    --gap: 24px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .carousel {
    scroll-behavior: auto;
  }
}

変更が必要な4箇所

変数 既定値 変更の目安
--edge1120px 1120px 自サイトのコンテンツ最大幅
--card clamp(240px, 78vw, 320px) スマホで次のカードが覗く幅に
--gap 16px / PC 24px 既存のデザインシステムの余白値
.carousel__itemの装飾 白背景+枠線 自前のカードスタイルへ

CSS変数にまとめてあるので、変更はこの4つだけで済みます。メディアクエリの中で変数を上書きすれば、レイアウトの分岐も1箇所で管理できます。

貼ったあとに確認する5点

順番に見ていくと、この記事で挙げた失敗をひととおり潰せます。

  1. 指を離したときにカードの左端が揃って止まるか
  2. 右端まで送ったとき、右側に余白が残っているか
  3. 右端でさらにスワイプしてもページごと動かないか(スマホ実機)
  4. Tabキーで枠が付き、左右キーで動くか
  5. Windows環境でスクロールバーが出ていないか

【画像挿入: 完成したカルーセルのスマホ表示。右端のカードが画面から半分はみ出し、続きがあることが分かる状態のスクリーンショット】


矢印ボタンを足す(JS 20行)

矢印ボタンを足す(JS 20行)

PCサイトでは矢印がないと存在に気づかれないことがあります。ここだけはJSが必要ですが、20行で済みます。

HTML

<div class="carousel-wrap">
  <button type="button" class="carousel-nav" data-dir="prev" aria-label="前へ">‹</button>

  <div class="carousel" id="js-carousel" tabindex="0" role="group" aria-label="おすすめ記事">
    <article class="carousel__item">カード1</article>
    <article class="carousel__item">カード2</article>
    <article class="carousel__item">カード3</article>
  </div>

  <button type="button" class="carousel-nav" data-dir="next" aria-label="次へ">›</button>
</div>

CSS(追加分)

.carousel-wrap {
  position: relative;
}

.carousel-nav {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  z-index: 2;
  width: 44px;
  height: 44px;
  border: 1px solid #ddd;
  border-radius: 50%;
  background: #fff;
  font-size: 20px;
  line-height: 1;
  cursor: pointer;
}

.carousel-nav[data-dir="prev"] { left: 8px; }
.carousel-nav[data-dir="next"] { right: 8px; }

.carousel-nav[disabled] {
  opacity: 0.3;
  cursor: default;
}

/* スマホでは矢印を隠す(指で送れるため) */
@media (max-width: 767px) {
  .carousel-nav { display: none; }
}

ボタンサイズを44pxにしているのは、タップ領域の目安として広く使われている値だからです。PCでも押しやすさは変わりません。矢印記号だけだとスクリーンリーダーが読み上げる内容が心もとないので、aria-labelは必ず付けます。

JavaScript

<script>
(function () {
  var track = document.getElementById('js-carousel');
  if (!track) return;

  var buttons = document.querySelectorAll('.carousel-nav');

  function step() {
    var item = track.querySelector('.carousel__item');
    if (!item) return track.clientWidth;
    var gap = parseFloat(getComputedStyle(track).columnGap) || 0;
    return item.getBoundingClientRect().width + gap;
  }

  function update() {
    var max = track.scrollWidth - track.clientWidth;
    buttons.forEach(function (btn) {
      if (btn.dataset.dir === 'prev') {
        btn.disabled = track.scrollLeft <= 1;
      } else {
        btn.disabled = track.scrollLeft >= max - 1;
      }
    });
  }

  buttons.forEach(function (btn) {
    btn.addEventListener('click', function () {
      var dir = btn.dataset.dir === 'next' ? 1 : -1;
      track.scrollBy({ left: step() * dir, behavior: 'smooth' });
    });
  });

  track.addEventListener('scroll', update, { passive: true });
  window.addEventListener('resize', update);
  update();
})();
</script>

なぜこう書くのか

カード幅を実測している理由。 step()getBoundingClientRect().widthを使い、CSSの値をJSに書き写していません。clamp()でカード幅が可変なので、ハードコードするとブレイクポイントを跨いだ瞬間にズレます。実測すれば、CSSを変えてもJSを触る必要がなくなります。

columnGapを足している理由。 カード1枚ぶんだけスクロールすると、gapのぶん手前で止まります。1枚ぶん+gapで、次のカードが正確に左端に来ます。

両端でボタンをdisabledにする理由。 押せるように見えて何も起きないボタンは、壊れていると受け取られます。scrollLeftscrollWidth - clientWidthを比べて判定し、1の余裕を持たせているのは、小数点の誤差で端に到達してもdisabledにならないケースを避けるためです。

{ passive: true }を付けている理由。 scrollイベントは高頻度で発火します。passiveを指定するとブラウザがスクロール処理をブロックしなくなり、体感が軽くなります。

IIFE(即時実行関数)で囲んでいる理由。 変数がグローバルに漏れません。1ページに複数のカルーセルを置く場合は、getElementByIdquerySelectorAll('.carousel')に変えてforEachで回す形に書き換えてください。その際、ボタンの取得もdocument.querySelectorAllではなく、各カルーセルのラッパー内に絞る必要があります。

document.querySelectorAll('.carousel-wrap').forEach(function (wrap) {
  var track = wrap.querySelector('.carousel');
  var buttons = wrap.querySelectorAll('.carousel-nav');
  // 以降は同じ処理
});

ラッパー単位で閉じないと、1つ目の矢印が2つ目のカルーセルまで動かします。複数設置は実案件で頻出するので、最初からこの形で書いておくほうが安全です。

置き場所

環境 置き場所
静的HTML </body>の直前
WordPress(ブロックテーマ) カスタムHTMLブロック、またはfunctions.phpからwp_enqueue_script()
WordPress(クラシックテーマ) 子テーマのfooter.php、またはfunctions.phpから読み込み
Shopify セクションの.liquidファイル内、{% schema %}の直前

WordPressでfunctions.phpから読み込む場合の注意は、テーマまわりの記事にまとめてあります。


ライブラリを使うべき場面

ライブラリを使うべき場面

scroll-snapで足りない要件もあります。判断の境界を書いておきます。

scroll-snapで足りるかの判断

scroll-snapで足りる

  • カードが横に並ぶ一覧
  • ロゴ・実績・商品の横並び
  • 矢印とドットまでのUI
  • スマホでスワイプできれば十分

ライブラリを検討する

  • 無限ループ(末尾から先頭へ繋ぐ)
  • 自動再生とホバー停止
  • フェード・3D等の切替演出
  • サムネイル連動の2連スライダー

無限ループはscroll-snapでは実装できません。要素を複製してスクロール位置を強制的に戻す手法はありますが、スナップと競合してカクつきます。ループが要件に入った時点でライブラリを選ぶほうが早いです。

自動再生も同様です。setIntervalscrollByを呼べば動きますが、ユーザーの操作中に止める処理、タブが非表示のときに止める処理まで書くと、結局ライブラリと同じ量のコードになります。

逆に言えば、上のリストの4つに当てはまらないなら、ライブラリを入れる理由はありません。ライブラリを1本読み込むだけでJSとCSSのリクエストが増え、メインスレッドの実行時間も乗ります。ファーストビューにカルーセルを置く設計では、その差がLCPに出ます。入れる・外すの判断は感覚ではなく、計測できる状態にしてから決めるのが確実です。


ギャラリー掲載サイトに見る横スクロールの使われ方

ギャラリー掲載サイトに見る横スクロールの使われ方

Soreiine!!ギャラリーに掲載されているサイトを見ていくと、横スクロールUIが効いている箇所には共通点があります。

【画像挿入: Soreiine!!ギャラリーの掲載サイト一覧から、横スクロールUIを採用しているサイトのキャプチャ3点】

使いどころ1:実績・事例の一覧。 制作会社サイトやブランドサイトで、実績サムネイルを横に並べる形。縦に積むと1画面を占有してしまうコンテンツを、横1列に収めて「量があること」を伝えています。カード幅を画面幅の8割弱に取り、次のカードが常に覗く設計になっているものが多く、スクロールバーを消してもスワイプできることが伝わります。この記事のclamp(240px, 78vw, 320px)は、まさにこの設計を数値に落としたものです。

使いどころ2:商品カテゴリのナビゲーション。 ECサイトのトップで、カテゴリのタイルを横並びにするパターン。ドロップダウンメニューに畳むより発見されやすく、スマホでの操作もそのまま指で送れます。scroll-snap-align: startで左端が揃うので、カテゴリ名の頭の位置が毎回同じになり、目線が迷いません。タイルの幅を不揃いにしているサイトでも、start揃えなら文字の起点だけは一定に保てます。

使いどころ3:導入企業ロゴ。 自動でループさせているサイトも多いですが、手動スクロールに留めているサイトのほうが、ロゴを一つひとつ確認できて印象に残ります。ロゴの並びは「読む」対象ではなく「眺める」対象なので、proximityで緩く吸い付かせる程度が自然です。mandatoryにすると1社ずつ止まってしまい、眺める動作を邪魔します。

見どころとして共通しているのは、横スクロールに載せているのが「並列で優劣のない情報」だけという点です。読ませたい順序があるコンテンツを横に流すと、後半が読まれません。並列だから横に流せる、という順序で設計されています。

もう1つ共通するのは、横スクロール領域の見出しが「一覧を見る」導線とセットになっていることです。横に流す設計は全件を見せるのに向かないので、続きを縦の一覧で受ける出口を必ず用意しています。カルーセルを単体で完結させず、一覧ページへの受け皿を作るところまでが設計に含まれます。

自社サイトに落とし込むときも同じ判断でいけます。「この5枚は順番に意味があるか」を先に問い、意味があるなら縦に積む。無いなら横スクロールが使えます。そのうえで「全部見たい人はどこへ行くか」を決める。この2問に答えられていれば、実装は記事のコードを貼るだけで済みます。


うまくいかないとき

うまくいかないとき

実装中に詰まりやすい箇所を、症状から引ける形でまとめました。

スナップが全く効かない

scroll-snap-typeを子要素に書いていませんか。 親(overflow-x: autoを持つ要素)に書きます。最も多い原因です。

親に書いてもダメな場合は、overflow-xautoまたはscrollになっているか確認してください。visiblehiddenではスナップが働きません。

横スクロールが発生しない

カードのflex指定を確認してください。flex: 1flex: 1 1 autoだと、カードが親の幅に収まるよう縮んでスクロールが起きません。flex: 0 0 【幅】の形にします。

flex-wrap: wrapが入っている場合も、折り返してしまって横スクロールになりません。Flexコンテナの既定はnowrapなので、明示的に指定していなければ問題ないはずです。

スナップ位置がズレてカードの左端が切れる

padding-inlineを書いてscroll-padding-inlineを書いていないケースです。この2つはセットで書きます。値も同じにします。

右端までスクロールすると余白が消える

Flexコンテナのpadding-rightがスクロール領域の末尾に反映されない挙動です。記事中の::after疑似要素を追加してください。flex: 0 0 1pxにすると確実です。

スクロールバーが消えない

3つのプロパティのうち、ブラウザに対応するものが抜けています。scrollbar-width: none-ms-overflow-style: none::-webkit-scrollbar { display: none }の3つすべてを書いているか確認してください。

::-webkit-scrollbar.carousel::-webkit-scrollbarのように、スクロールする要素自身に対して書きます。親や子に書くと効きません。

スマホで横スワイプすると「戻る」が発動する

overscroll-behavior-x: containが入っていません。カルーセルの親要素に追加してください。

矢印を押しても1枚ぶん進まない

JSのstep()でgapを足し忘れているか、columnGapが取得できていない可能性があります。gap: 16pxのようにショートハンドで書いた場合、getComputedStyleではcolumnGapとして取得できるはずですが、環境によってはnormalが返ります。その場合はparseFloatNaNになるので、|| 0のフォールバックが効いて0扱いになります。CSS変数から読む形に書き換えると確実です。

var gap = parseFloat(getComputedStyle(track).getPropertyValue('--gap')) || 0;

カードの中身の高さが揃わない

Flexアイテムは既定で高さが揃います(align-items: stretch)。揃っていない場合は、align-itemsに別の値が入っているか、カード内部のレイアウトの問題です。カード内で高さを揃えたいなら、カード自身もdisplay: flex; flex-direction: column;にして、下部の要素にmargin-top: autoを当てます。

親要素側に横スクロールバーが出る

カルーセルを100vwで画面幅いっぱいに広げたときに起きます。100vwは縦スクロールバーの幅を含むので、その分だけページ全体がはみ出します。width: 100%と負のマージンで広げるか、margin-inline: calc(50% - 50vw)ではなくcalc(-1 * var(--edge))のように内側の余白から逆算する形にすると回避できます。


よくある質問

よくある質問

scroll-snapはIEでも動きますか

動きません。IE10・11には旧仕様の-ms-scroll-snap-points-xがありますが、書き方が違ううえに挙動も安定しません。IE対応が必須の要件なら、この記事の手法は使わずライブラリを検討してください。現行のChrome・Edge・Firefox・Safariであれば問題なく動作します。

mandatoryとproximityはどちらを使うべきですか

各カードがコンテナに収まる大きさならmandatory、収まらない可能性があるならproximityです。mandatoryは必ずスナップ位置で止まるため、コンテナより大きい要素があると途中で止まれず内容が読めなくなります。カード型の一覧UIであればmandatoryで問題ありません。

無限ループのカルーセルは作れますか

scroll-snapだけでは作れません。要素を複製してスクロール位置を戻す手法はありますが、スナップの吸着と競合してカクつきが出ます。無限ループが要件なら、Swiperなどのライブラリを使うほうが結果的に早く安定します。

スクロールバーを消すのはアクセシビリティ上問題になりませんか

消すこと自体は問題になりませんが、代替の手がかりが必要です。カードを画面から少しはみ出させる、矢印ボタンを置く、tabindex="0"でキーボード操作を通す、のいずれか(できれば全部)を実装してください。フォーカスリングをoutline: noneで消すのは避け、必要なら:focus-visibleで自前のスタイルに置き換えます。

ドットインジケーターも付けられますか

付けられます。ただしCSSだけでは現在位置を検知できないため、JSが必要です。IntersectionObserverで各カードの可視状態を監視し、中央にあるカードに対応するドットにクラスを付ける形が一般的です。矢印だけで足りるケースが多いので、要件を確認してから足すことをおすすめします。

1ページに複数のカルーセルを置けますか

置けます。CSSはクラス指定なのでそのまま複数動きますが、JSはgetElementByIdのままだと最初の1つしか対象になりません。記事中の.carousel-wrap単位で回す書き方に変えて、トラックとボタンをラッパー内から取得してください。ラッパーで閉じないと、片方の矢印がもう片方のカルーセルを動かします。


まとめ

scroll-snapでカルーセルを作るとき、押さえるべきは4点です。

  1. scroll-snap-typeは親、scroll-snap-alignは子 — ここを間違えると何も起きない
  2. カードはflex: 0 0 【幅】0 0でないと横スクロールが発生しない
  3. padding-inlinescroll-padding-inlineはセット — 片方だけだとスナップ位置がズレる
  4. overscroll-behavior-x: containを入れる — スマホの「戻る」誤発動を防ぐ

無限ループと自動再生が要件に入らないなら、ライブラリは不要です。CSS 40行とJS 20行で、読み込みを増やさずに同じUIが作れます。

記事中の完成形コードは変数4つを書き換えるだけで動くようにしてあるので、まずそのまま貼って、右端まで送って余白を確認するところから始めてください。