TECH · カルーセル

ライブラリなしで横に流して見せる

サンプルとコード

01

scroll-snap だけの横送り

単独で開く ↗

JavaScriptは1行もありません。スワイプの慣性も端末ごとの操作感も、ブラウザのスクロールをそのまま使います。書き忘れやすい指定に注釈を付けました。

HTML
<!-- 横並びの一覧なので ul にする。見た目のためだけの div ではなく、
     「5件の並び」であることを構造で伝える。 -->
<ul class="rail" style="list-style: none; margin: 0; padding-left: 0;">
  <li class="slide">
    <div class="slide-shot sw-a"></div>
    <div class="slide-body">
      <h3 class="slide-title">スナップは指定するだけ</h3>
      <p class="slide-text">位置の計算も慣性の再現も要りません。ブラウザのスクロールをそのまま使います。</p>
    </div>
  </li>
  <li class="slide">
    <div class="slide-shot sw-b"></div>
    <div class="slide-body">
      <h3 class="slide-title">縮まないようにする</h3>
      <p class="slide-text">flex: 0 0 auto を忘れると全部が1画面に詰め込まれ、スクロールが生まれません。</p>
    </div>
  </li>
  <li class="slide">
    <div class="slide-shot sw-c"></div>
    <div class="slide-body">
      <h3 class="slide-title">スクロールバーは消さない</h3>
      <p class="slide-text">消すと動かせることが伝わりません。細くして目立たせないだけにします。</p>
    </div>
  </li>
  <li class="slide">
    <div class="slide-shot sw-d"></div>
    <div class="slide-body">
      <h3 class="slide-title">端の余白は2つ書く</h3>
      <p class="slide-text">padding だけだと最後の1枚が端まで行けません。scroll-padding も同じ値にします。</p>
    </div>
  </li>
  <li class="slide">
    <div class="slide-shot sw-e"></div>
    <div class="slide-body">
      <h3 class="slide-title">1枚ずつ送る</h3>
      <p class="slide-text">scroll-snap-stop: always を付けると、勢いよく振っても飛ばされません。</p>
    </div>
  </li>
</ul>
CSS
.rail {
  display: flex;
  gap: 1rem;
  overflow-x: auto;

  /* これが本体。x 方向にスナップし、mandatory なので
     指を離すと必ずどれかの位置に吸い付く。proximity にすると
     「近ければ吸い付く」になり、中途半端な位置で止まれる。 */
  scroll-snap-type: x mandatory;

  /* 端の余白。padding だけだと最後の1枚が端まで行けないので、
     scroll-padding で「スナップ位置の基準」もずらす。 */
  padding-inline: .25rem;
  scroll-padding-inline: .25rem;

  /* iOS で慣性スクロールの途中に別の要素へ引っかからないようにする。 */
  overscroll-behavior-x: contain;

  /* スクロールバーは残す。消すと「動かせること」が伝わらなくなる。
     細くして目立たせないだけにとどめる。 */
  scrollbar-width: thin;
  padding-bottom: .6rem;
}

.slide {
  /* flex の縮みを止める。これを書かないと全部が1画面に詰め込まれ、
     そもそもスクロールが生まれない。最頻出の書き忘れ。 */
  flex: 0 0 auto;
  width: min(72%, 320px);

  /* 各要素のどこを基準に吸い付くか。start なら左端が揃う。
     center にすると中央寄せのカルーセルになる。 */
  scroll-snap-align: start;

  /* スクロール中にこの要素を飛び越して次へ行かせない。
     1枚ずつ確実に送りたいときに効く。 */
  scroll-snap-stop: always;

  border-radius: 14px;
  border: 1px solid var(--line);
  background: #fff;
  overflow: hidden;
}
.slide-shot { aspect-ratio: 16 / 10; }
.slide-body { padding: .8rem .9rem 1rem; }
.slide-title { margin: 0; font-size: .98rem; line-height: 1.6; }
.slide-text { margin: .3rem 0 0; font-size: .84rem; line-height: 1.85; color: var(--muted); }

/* 1画面に複数枚入るレールでは、最後の要素の「左端揃え」の位置が
   スクロールできる最大値を超える。mandatory は必ずどれかの
   スナップ位置に吸い付くので、そのままだと末尾の数枚に
   到達できなくなる。最後だけ右端揃えにして、
   スクロールの終端を有効なスナップ位置にする。 */
.slide:last-child { scroll-snap-align: end; }

/* 動きを減らす設定では、スナップの滑らかな移動を止める。
   スナップ自体は残るので、機能は失われない。 */
@media (prefers-reduced-motion: reduce) {
  .rail { scroll-behavior: auto; }
}
AIへの指示文
CSSのscroll-snapだけで横送りのカルーセルを作ってください。
JavaScriptは使いません。
要件:
- 外側に scroll-snap-type: x mandatory、各要素に scroll-snap-align: start。
  mandatory と proximity の違いをコメントで書く。
- 各要素に flex: 0 0 auto を指定する。これを書かないと全部が1画面に
  詰め込まれてスクロールが生まれない、という最頻出の書き忘れを書く。
- scroll-snap-stop: always で1枚ずつ確実に送る。
- 最後の要素だけ scroll-snap-align: end にする。1画面に複数枚入る
  レールでは末尾の要素の「左端揃え」の位置がスクロール最大値を
  超えるため、mandatory だと末尾に到達できなくなる、と理由を書く。
- 端の余白は padding だけでなく scroll-padding にも同じ値を入れる。
  padding だけだと最後の1枚が端まで行けない理由を書く。
- overscroll-behavior-x: contain を指定する。
- スクロールバーは消さず細くするだけにする。消すと動かせることが
  伝わらなくなる、という理由をコメントで書く。
- 横並びの一覧なので ul と li で組む。
- prefers-reduced-motion では scroll-behavior を auto にする。
- 日本語の本文なので line-height は 1.85、letter-spacing は 0 にする。
02

現在位置を IntersectionObserver で追う

単独で開く ↗

スワイプ・ボタン・ドットのどれで動かしても表示が一致します。位置は scrollLeft の割り算ではなく「実際に見えているか」で判定するので、余白や丸め誤差でずれません。

HTML
<!-- aria-roledescription で「これはカルーセルだ」と伝える。
     スクリーンリーダーは「カルーセル、4件のリスト」のように読む。 -->
<div class="carousel" role="group" aria-roledescription="カルーセル" aria-label="実績の紹介">
  <!-- スクロールできる領域はキーボードでも操作できる必要がある。
       tabindex="0" で focus を受け取れるようにし、aria-label で名前を付ける。
       Chrome は自動で focusable にするが、他のブラウザでは付けないと
       矢印キーで動かせない。role="region" は当てない。ul の list 役割を
       上書きしてしまい、「4件のリスト」という情報が消えるため。 -->
  <ul class="rail" id="rail" tabindex="0"
      aria-label="実績(横スクロール・左右キーで移動)">
    <li class="slide" aria-label="1/4">
      <div class="slide-shot sw-a"></div>
      <div class="slide-body"><h3 class="slide-title">位置の判定は1か所に集める</h3></div>
    </li>
    <li class="slide" aria-label="2/4">
      <div class="slide-shot sw-b"></div>
      <div class="slide-body"><h3 class="slide-title">スワイプでもボタンでも同じ結果に</h3></div>
    </li>
    <li class="slide" aria-label="3/4">
      <div class="slide-shot sw-c"></div>
      <div class="slide-body"><h3 class="slide-title">端に着いたらボタンを無効にする</h3></div>
    </li>
    <li class="slide" aria-label="4/4">
      <div class="slide-shot sw-d"></div>
      <div class="slide-body"><h3 class="slide-title">現在地は aria-current で持つ</h3></div>
    </li>
  </ul>

  <div class="bar">
    <button class="nav-btn" id="prev" aria-label="前へ">←</button>
    <button class="nav-btn" id="next" aria-label="次へ">→</button>
    <!-- tablist にはしない。tab と tabpanel の対応が無いのに
         role だけ付けると、読み上げが実態と食い違う。 -->
    <div class="dots" id="dots" role="group" aria-label="表示位置"></div>
  </div>
</div>
CSS
.carousel { position: relative; }

.rail {
  display: flex;
  gap: 1rem;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-behavior: smooth;
  list-style: none;
  margin: 0;
  padding: 0 0 .6rem;
  scrollbar-width: thin;
}
.slide {
  /* ドットで現在地を示すなら、1枚ずつ見せる。1画面に複数枚入る
     レールでは「いま何枚目か」が定義できず、さらに末尾の要素の
     左端揃えの位置がスクロール最大値を超えて到達できなくなる。 */
  flex: 0 0 100%;
  scroll-snap-align: start;
  scroll-snap-stop: always;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: #fff;
  overflow: hidden;
}
.slide-shot { aspect-ratio: 16 / 9; }
.slide-body { padding: .8rem .9rem 1rem; }
.slide-title { margin: 0; font-size: .98rem; line-height: 1.6; }

.bar { display: flex; align-items: center; gap: .6rem; margin-top: .8rem; }

.nav-btn {
  width: 2.1rem; height: 2.1rem; flex: none;
  border: 1px solid var(--line); border-radius: 999px;
  background: #fff; color: var(--muted); cursor: pointer;
  font-family: inherit; font-size: .9rem; line-height: 1;
}
.nav-btn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
/* 端に着いたら押せなくする。押しても何も起きないボタンは、
   壊れているのか端なのかが利用者に区別できない。 */
.nav-btn:disabled { opacity: .35; cursor: default; }

.dots { display: flex; gap: .4rem; margin-left: auto; }
.dot {
  width: .55rem; height: .55rem; padding: 0;
  border: 0; border-radius: 999px; cursor: pointer;
  background: var(--line);
  transition: background .2s, transform .2s;
}
/* 現在地は aria-current で持つ。見た目のクラスを別に足すと、
   状態が2か所に散って必ずずれる。 */
.dot[aria-current="true"] { background: var(--accent); transform: scale(1.25); }

@media (prefers-reduced-motion: reduce) {
  .rail { scroll-behavior: auto; }
  .dot { transition: none; }
}
JavaScript
const rail = document.getElementById("rail");
const slides = [...rail.children];
const dots = document.getElementById("dots");
const prev = document.getElementById("prev");
const next = document.getElementById("next");
let current = 0;

// ドットは枚数から作る。HTMLに手で並べると、枚数を変えたときに
// 必ず片方だけ直し忘れる。
slides.forEach((_, i) => {
  const b = document.createElement("button");
  b.className = "dot";
  b.type = "button";
  b.setAttribute("aria-label", `${i + 1}枚目へ`);
  b.addEventListener("click", () => go(i));
  dots.append(b);
});

function go(i) {
  // scrollIntoView に任せる。座標の計算をしないので、
  // 余白やレイアウトが変わっても壊れない。
  slides[i].scrollIntoView({ behavior: "smooth", block: "nearest", inline: "start" });
}

// 現在地の更新はここだけ。スワイプ・ボタン・ドットのどれで動かしても、
// 最後は必ずスクロールが起きるので、この1か所に集約できる。
function render() {
  [...dots.children].forEach((d, i) =>
    d.setAttribute("aria-current", String(i === current)),
  );
  prev.disabled = current === 0;
  next.disabled = current === slides.length - 1;
}

// scrollLeft を幅で割る実装は、余白や端末ごとの丸めでずれる。
// 「実際に見えているか」はブラウザが知っているので、それを聞く。
// 見えている面積がいちばん大きいものを現在地とする。
// 「最初に交差した要素」を採ると、2枚が同時に見えている瞬間に
// 右側の要素を拾ってしまい、表示が1つ先へずれる。
const ratios = new Map(slides.map((s) => [s, 0]));
const io = new IntersectionObserver(
  (entries) => {
    for (const e of entries) ratios.set(e.target, e.intersectionRatio);
    let best = 0;
    for (let i = 1; i < slides.length; i++) {
      // 同率のときは先頭側を採るため、わずかに上回った場合だけ入れ替える。
      if (ratios.get(slides[i]) > ratios.get(slides[best]) + 0.01) best = i;
    }
    if (best === current) return;
    current = best;
    render();
  },
  // レール自身を基準にする。段階的な閾値を渡すと、
  // 少し動くたびに面積が更新されて追随が滑らかになる。
  { root: rail, threshold: [0, 0.25, 0.5, 0.75, 1] },
);
slides.forEach((s) => io.observe(s));

prev.addEventListener("click", () => go(Math.max(0, current - 1)));
next.addEventListener("click", () => go(Math.min(slides.length - 1, current + 1)));

// 矢印キーでも動かせるようにする。マウスもタッチも使わない利用者が
// カルーセルから抜け出せなくなるのを防ぐ。
rail.addEventListener("keydown", (e) => {
  if (e.key === "ArrowRight") { e.preventDefault(); next.click(); }
  if (e.key === "ArrowLeft") { e.preventDefault(); prev.click(); }
});

render();
AIへの指示文
前後ボタンとドットの付いたカルーセルを作ってください。
要件:
- 各要素は flex: 0 0 100% にして1枚ずつ見せる。ドットで現在地を
  示すなら「いま何枚目か」が定義できる必要がある、と理由を書く。
- 移動は scrollIntoView に任せ、座標の計算をしない。
- 現在位置は IntersectionObserver で判定する。scrollLeft を幅で割る
  実装は余白や端末ごとの丸めでずれる、という理由をコメントで書く。
- 判定は「見えている面積がいちばん大きい要素」にする。
  「最初に交差した要素」を採ると、2枚が同時に見えている瞬間に
  右側を拾って表示が1つ先へずれる、と理由を書く。
  threshold は [0, 0.25, 0.5, 0.75, 1] のように段階で渡す。
- 現在地は aria-current で持ち、見た目はその属性から CSS で描く。
  状態を2か所に持つと必ずずれる、と書く。
- ドットは枚数から JavaScript で生成する。HTMLに手で並べると
  枚数を変えたときに片方だけ直し忘れる、と書く。
- 端に着いたら前後ボタンを disabled にする。押しても何も起きない
  ボタンは、壊れているのか端なのか区別が付かない、と書く。
- レールに tabindex="0" と aria-label を付け、左右キーで動かせるようにする。
  role="region" は当てない(ul の list 役割を上書きしてしまうため)。
- 外側は role="group" と aria-roledescription="カルーセル" で包む。
- ドットの入れ物を role="tablist" にはしない。tab と tabpanel の
  対応が無いのに role だけ付けると読み上げが実態と食い違う、と書く。
03

サムネイル連動のギャラリー

単独で開く ↗

サムネイルから主役へ、主役からサムネイルへ、どちらの向きにも追随します。選択状態は色だけでなく枠線と明度の2つで示しています。

HTML
<div role="group" aria-roledescription="ギャラリー" aria-label="商品写真">
  <ul class="stage" id="stage" tabindex="0" aria-label="商品写真(横スクロール・左右キーで移動)">
    <li class="shot sw-a" data-caption="正面。素材の質感が分かる標準的な角度です。"></li>
    <li class="shot sw-b" data-caption="斜め上から。厚みと縁の処理が見えます。"></li>
    <li class="shot sw-c" data-caption="背面。端子の位置と刻印を確認できます。"></li>
    <li class="shot sw-d" data-caption="使用時。手に持ったときの大きさの目安です。"></li>
    <li class="shot sw-e" data-caption="同梱物。付属品を並べた状態です。"></li>
  </ul>

  <!-- サムネイルは button。div に click を付けるとキーボードで押せない。 -->
  <ul class="thumbs" id="thumbs"></ul>

  <!-- 説明文の差し替えは aria-live で伝える。見えている人には
       文字が変わって分かるが、そうでない人には何も起きていない。 -->
  <p class="caption" id="caption" aria-live="polite"></p>
</div>
CSS
.stage {
  display: flex;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-behavior: smooth;
  list-style: none;
  margin: 0;
  padding: 0;
  border-radius: 14px;
  border: 1px solid var(--line);
  background: #fff;
  /* 主役側のスクロールバーは隠す。サムネイルが位置を示すので、
     ここに二重の手がかりは要らない。ただし操作は殺さない。 */
  scrollbar-width: none;
}
.stage::-webkit-scrollbar { display: none; }

.shot {
  flex: 0 0 100%;
  scroll-snap-align: center;
  scroll-snap-stop: always;
  aspect-ratio: 16 / 10;
}

.thumbs {
  display: flex;
  gap: .5rem;
  overflow-x: auto;
  list-style: none;
  margin: .7rem 0 0;
  padding: 0 0 .4rem;
  scrollbar-width: thin;
}
.thumb {
  flex: 0 0 auto;
  width: 68px;
  aspect-ratio: 16 / 10;
  padding: 0;
  border: 2px solid transparent;
  border-radius: 8px;
  cursor: pointer;
  overflow: hidden;
  /* 選択中でない側は少し落とす。色だけで示すと、
     色覚特性によっては区別が付かない。枠線と明度の両方で示す。 */
  opacity: .55;
  transition: opacity .2s, border-color .2s;
}
.thumb[aria-current="true"] {
  opacity: 1;
  border-color: var(--accent);
}
.thumb:hover { opacity: 1; }
.thumb > span { display: block; width: 100%; height: 100%; }

.caption {
  margin: .6rem 0 0;
  font-size: .86rem;
  line-height: 1.85;
  color: var(--muted);
  /* 切り替えで高さが変わると下の要素が跳ねる。2行ぶん確保しておく。 */
  min-height: 3.2em;
}

@media (prefers-reduced-motion: reduce) {
  .stage { scroll-behavior: auto; }
  .thumb { transition: none; }
}
JavaScript
const stage = document.getElementById("stage");
const thumbs = document.getElementById("thumbs");
const caption = document.getElementById("caption");
const shots = [...stage.children];
let current = 0;

// サムネイルは主役の枚数から作る。HTMLに二重で並べると、
// 写真を増やしたときに必ず片方だけ直し忘れる。
shots.forEach((shot, i) => {
  const li = document.createElement("li");
  const b = document.createElement("button");
  b.type = "button";
  b.className = "thumb";
  b.setAttribute("aria-label", `${i + 1}枚目:${shot.dataset.caption}`);
  const inner = document.createElement("span");
  // 主役と同じ見た目を使い回す。クラス名を二重管理しない。
  inner.className = shot.className.replace("shot", "").trim();
  b.append(inner);
  b.addEventListener("click", () => {
    shot.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" });
  });
  li.append(b);
  thumbs.append(li);
});

function render() {
  [...thumbs.querySelectorAll(".thumb")].forEach((t, i) =>
    t.setAttribute("aria-current", String(i === current)),
  );
  caption.textContent = shots[current].dataset.caption;

  // 選択中のサムネイルが画面外にあると、どれが選ばれているか分からない。
  // 主役に追従してサムネイル側もスクロールさせる。
  thumbs.querySelectorAll(".thumb")[current]
    ?.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "nearest" });
}

// 見えている面積がいちばん大きいものを現在地とする。「最初に交差した
// 要素」を採ると、切り替わりの途中で次の写真を先に拾ってしまう。
const ratios = new Map(shots.map((s) => [s, 0]));
const io = new IntersectionObserver(
  (entries) => {
    for (const e of entries) ratios.set(e.target, e.intersectionRatio);
    let best = 0;
    for (let i = 1; i < shots.length; i++) {
      if (ratios.get(shots[i]) > ratios.get(shots[best]) + 0.01) best = i;
    }
    if (best === current) return;
    current = best;
    render();
  },
  { root: stage, threshold: [0, 0.25, 0.5, 0.75, 1] },
);
shots.forEach((s) => io.observe(s));

stage.addEventListener("keydown", (e) => {
  const d = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
  if (!d) return;
  e.preventDefault();
  const i = Math.min(shots.length - 1, Math.max(0, current + d));
  shots[i].scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" });
});

render();
AIへの指示文
商品写真のギャラリーを、サムネイル連動で作ってください。
要件:
- 主役は scroll-snap の横スクロール、サムネイルは別の横スクロール。
- サムネイルは button にする。div に click を付けるとキーボードで
  押せない、という理由をコメントで書く。
- サムネイルは主役の枚数から生成し、見た目のクラスも主役から
  使い回す。HTMLに二重で並べると写真を増やしたとき片方だけ
  直し忘れる、と書く。
- 選択状態は色だけで示さず、枠線と明度の2つで示す。
  色覚特性によっては色だけでは区別が付かない、と書く。
- 主役をスワイプしたら、選択中のサムネイルも画面内へスクロールさせる。
- 説明文の差し替えは aria-live="polite" で伝える。
- 説明文の高さを min-height で確保し、切り替えで下が跳ねないようにする。
04

自動送りと、4つの停止条件

単独で開く ↗

ポインタ・フォーカス・タブの表示状態・本人の操作。停止理由を集合で持つことで、ポインタを外した瞬間にフォーカス中でも再開してしまう事故を防いでいます。

HTML
<div role="group" aria-roledescription="カルーセル" aria-label="お客様の声">
  <ul class="rail" id="rail" tabindex="0" aria-label="お客様の声(横スクロール・左右キーで移動)">
    <li class="slide">
      <p class="slide-eyebrow">制作会社</p>
      <h3 class="slide-title">止められない自動送りは、読ませない</h3>
      <p class="slide-text">読んでいる途中で流れると、利用者は最初から読み直すか、諦めます。</p>
    </li>
    <li class="slide">
      <p class="slide-eyebrow">事業会社</p>
      <h3 class="slide-title">止める条件は4つある</h3>
      <p class="slide-text">ポインタ、フォーカス、タブの表示状態、そして本人の操作。どれも「読んでいる」合図です。</p>
    </li>
    <li class="slide">
      <p class="slide-eyebrow">個人開発</p>
      <h3 class="slide-title">一度自分で動かしたら、もう戻さない</h3>
      <p class="slide-text">操作した人には意思があります。そのあと勝手に動き出すのは邪魔にしかなりません。</p>
    </li>
  </ul>

  <div class="progress" id="progress" data-paused="false" style="--dwell: 4s"><i></i></div>

  <div class="bar">
    <!-- 停止ボタンは必須。WCAG 2.2.2 は「5秒以上続く自動的な動きには
         停止手段を用意すること」を求めている。 -->
    <button class="toggle" id="toggle" aria-pressed="false">自動送りを止める</button>
    <div class="dots" id="dots" role="group" aria-label="表示位置"></div>
  </div>
</div>
CSS
.rail {
  display: flex;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-behavior: smooth;
  list-style: none;
  margin: 0;
  padding: 0;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: #fff;
  scrollbar-width: none;
}
.rail::-webkit-scrollbar { display: none; }

.slide {
  flex: 0 0 100%;
  scroll-snap-align: center;
  scroll-snap-stop: always;
  padding: clamp(1.2rem, 4vw, 2rem);
  min-height: 8.5rem;
}
.slide-eyebrow { margin: 0 0 .3rem; font-size: .74rem; line-height: 1.8; color: var(--accent); }
.slide-title { margin: 0; font-size: 1.05rem; line-height: 1.6; }
.slide-text { margin: .4rem 0 0; font-size: .88rem; line-height: 1.85; color: var(--muted); }

.bar { display: flex; align-items: center; gap: .6rem; margin-top: .7rem; }

.toggle {
  padding: .35rem .9rem;
  border: 1px solid var(--line); border-radius: 999px;
  background: #fff; color: var(--muted); cursor: pointer;
  font-family: inherit; font-size: .82rem; line-height: 1.8;
}
.toggle:hover { border-color: var(--accent); color: var(--accent); }

.dots { display: flex; gap: .4rem; margin-left: auto; }
.dot {
  width: .5rem; height: .5rem; padding: 0; border: 0; border-radius: 999px;
  background: var(--line); cursor: pointer;
}
.dot[aria-current="true"] { background: var(--accent); }

/* 進み具合の帯。止まっているときは animation-play-state で
   その場に留める。幅を0に戻すと「最初からやり直し」に見えてしまう。 */
.progress { height: 2px; background: var(--line); border-radius: 2px; overflow: hidden; }
.progress > i {
  display: block; height: 100%; width: 100%;
  background: var(--accent);
  transform-origin: left;
  animation: fill var(--dwell) linear infinite;
}
.progress[data-paused="true"] > i { animation-play-state: paused; }
@keyframes fill { from { transform: scaleX(0); } to { transform: scaleX(1); } }

/* 動きを減らす設定では帯自体を出さない。自動送りもJS側で止める。 */
@media (prefers-reduced-motion: reduce) {
  .rail { scroll-behavior: auto; }
  .progress { display: none; }
}
JavaScript
const DWELL = 4000;

const rail = document.getElementById("rail");
const slides = [...rail.children];
const dots = document.getElementById("dots");
const toggle = document.getElementById("toggle");
const progress = document.getElementById("progress");
let current = 0;
let timer = null;

// 「止まっている理由」を集合で持つ。真偽値1つで管理すると、
// ポインタを外した瞬間にフォーカス中でも再開してしまう。
const holds = new Set();

// 動きを減らす設定の人には、そもそも自動送りをしない。
const reduced = matchMedia("(prefers-reduced-motion: reduce)");
if (reduced.matches) holds.add("reduced-motion");

slides.forEach((_, i) => {
  const b = document.createElement("button");
  b.type = "button";
  b.className = "dot";
  b.setAttribute("aria-label", `${i + 1}枚目へ`);
  b.addEventListener("click", () => {
    hold("user");          // 自分で選んだ人には、もう自動で動かさない
    go(i);
  });
  dots.append(b);
});

function go(i) {
  slides[i].scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" });
}

function render() {
  [...dots.children].forEach((d, i) =>
    d.setAttribute("aria-current", String(i === current)),
  );
  const paused = holds.size > 0;
  progress.dataset.paused = String(paused);
  toggle.setAttribute("aria-pressed", String(holds.has("user")));
  toggle.textContent = holds.has("user") ? "自動送りを再開する" : "自動送りを止める";
  document.getElementById("state").textContent =
    paused ? `停止中(理由: ${[...holds].join(", ")})` : "自動送り中";
}

function tick() {
  if (holds.size) return;
  go((current + 1) % slides.length);
}

function hold(reason) {
  holds.add(reason);
  clearInterval(timer);
  timer = null;
  render();
}

function release(reason) {
  holds.delete(reason);
  if (holds.size === 0 && timer === null) timer = setInterval(tick, DWELL);
  render();
}

// 1. ポインタが乗っている間
rail.addEventListener("pointerenter", () => hold("hover"));
rail.addEventListener("pointerleave", () => release("hover"));

// 2. 中の要素にフォーカスがある間(キーボード利用者が読んでいる)
rail.addEventListener("focusin", () => hold("focus"));
rail.addEventListener("focusout", () => release("focus"));

// 3. タブが裏に回っている間。見えていないものを送っても意味が無く、
//    戻ってきたときに何枚も飛んでいるのは混乱のもとになる。
document.addEventListener("visibilitychange", () =>
  document.hidden ? hold("hidden") : release("hidden"),
);

// 4. 停止ボタン。押したら明示的な意思なので、こちらから再開はしない。
toggle.addEventListener("click", () =>
  holds.has("user") ? release("user") : hold("user"),
);

// 見えている面積がいちばん大きいものを現在地とする。
const ratios = new Map(slides.map((s) => [s, 0]));
const io = new IntersectionObserver(
  (entries) => {
    for (const e of entries) ratios.set(e.target, e.intersectionRatio);
    let best = 0;
    for (let i = 1; i < slides.length; i++) {
      if (ratios.get(slides[i]) > ratios.get(slides[best]) + 0.01) best = i;
    }
    if (best === current) return;
    current = best;
    // 送るたびに帯を頭から流し直す。アニメーションを一度 none に
    // 戻して強制的に再計算させるのが、いちばん短く確実。
    const bar = progress.firstElementChild;
    bar.style.animation = "none";
    void bar.offsetWidth;
    bar.style.animation = "";
    render();
  },
  { root: rail, threshold: [0, 0.25, 0.5, 0.75, 1] },
);
slides.forEach((s) => io.observe(s));

rail.addEventListener("keydown", (e) => {
  const d = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
  if (!d) return;
  e.preventDefault();
  hold("user");
  go(Math.min(slides.length - 1, Math.max(0, current + d)));
});

progress.style.setProperty("--dwell", DWELL + "ms");
if (!holds.size) timer = setInterval(tick, DWELL);
render();
AIへの指示文
自動送りのカルーセルを、止める条件まで含めて作ってください。
要件:
- 停止理由を Set で持つ。真偽値1つで管理すると、ポインタを外した
  瞬間にフォーカス中でも再開してしまう、という理由をコメントで書く。
- 止める条件は4つ。ポインタが乗っている間(pointerenter/leave)、
  中にフォーカスがある間(focusin/focusout)、タブが裏に回っている間
  (visibilitychange)、そして停止ボタン。
- 停止ボタンは必須。WCAG 2.2.2 が5秒以上続く自動的な動きに
  停止手段を求めている、と書く。aria-pressed で状態を持つ。
- 利用者が自分でドットや矢印キーを操作したら、以降は自動で
  動かさない。操作した人には意思がある、という理由を書く。
- prefers-reduced-motion では最初から自動送りをしない。
- 進み具合の帯は animation-play-state: paused で止める。
  幅を0に戻すと「最初からやり直し」に見える、と書く。
- 自動送りはそもそも必要かを疑うべきで、載せたいものが多いという
  作り手の都合であることが大半だ、という注意を本文に置く。

この技術について

カルーセルは長いあいだライブラリの領分でした。位置の計算、慣性のあるスワイプ、リサイズへの追随。どれも自分で書くのは面倒だったからです。

いまはブラウザ側が済ませます。scroll-snap-type: x mandatory を指定すれば、スワイプの慣性も端末ごとの操作感もそのまま使えて、指を離せば勝手に吸い付く。残る仕事は、いまどこを見ているかを伝えることと、止められるようにすることだけです。

最初に疑うこと

技術の前に一つ。2枚目以降はほとんど見られません。

伝えたい内容が複数あるなら、縦に並べるほうが確実に読まれます。カルーセルが向くのは、写真のように「同じ種類のものを何点か見せる」場合です。性質の違う訴求を順番に流すのは、作り手の都合であって利用者の利益ではありません。

いちばん伝えたい内容は、必ず1枚目に置いてください。

横に流すと2枚目以降は見られない。縦に並べれば全部が視界に入る 横に流す 1枚目 2枚目 2枚目以降は、ほとんど見られない 縦に並べる 全部が視界に入る
伝えたいものが複数あるなら、この比較で答えは出ます。カルーセルが向くのは「同じ種類のものを何点か見せる」場合だけです。写真の一覧はよく、性質の違う訴求を順に流すのは向きません。

書き忘れると動かない指定

scroll-snap で最も多い失敗は、子要素に flex: 0 0 auto を書き忘れることです。

flex アイテムは既定で縮むので、5枚あっても全部が1画面に押し込まれます。すると横方向のはみ出しが生まれず、スクロール自体が発生しません。「スナップが効かない」の大半はこれです。

もう一つが端の余白です。padding-inline だけだと、最後の1枚がスナップ位置の関係で端まで行けません。scroll-padding-inline にも同じ値を入れてください。

flex: 0 0 auto を書かないと全部が1画面に詰め込まれ、スクロール自体が生まれない flex: 0 0 auto を書かない 5枚が1画面に詰め込まれる。はみ出しが生まれず、 そもそもスクロールが発生しない flex: 0 0 auto を書く はみ出すので、横にスクロールできる
左は5枚を並べただけの状態です。横にはみ出していないので、スクロールする余地がありません。スナップの指定を読み返す前に、右のようにはみ出しているかを先に確かめてください。

末尾に到達できなくなる罠

1画面に複数枚入るレールで scroll-snap-align: start を全要素に付けると、末尾の数枚に到達できなくなります。

理由は単純な算数です。5枚を横に並べたレールで、最後の1枚の「左端揃え」の位置は、スクロールできる最大値を超えています。mandatory は必ずどれかのスナップ位置に吸い付くので、届かない位置は候補から外れ、手前で止まってしまう。

対処は、最後の要素だけ終端に揃えることです。

.slide:last-child { scroll-snap-align: end; }

これでスクロールの終端が有効なスナップ位置になり、末尾まで届きます。ドットや前後ボタンを付けるなら、そもそも1枚ずつ見せる(flex: 0 0 100%)ほうが素直です。複数枚が同時に見えるレールでは「いま何枚目か」が定義できません。

1画面に複数枚入るレールでは、末尾の要素の左端揃えの位置がスクロール最大値を超えて到達できなくなる 見えている範囲 0 336 672 1008 1344 スクロールできる最大値は 948 → 672 より先の位置には届かない .slide:last-child { scroll-snap-align: end } 最後だけ右端揃えにすると、スクロールの終端が有効なスナップ位置になる
数字を並べると単純な算数です。届かないスナップ位置は候補から外れるので、手前で止まったまま先へ進めなくなります。ドットや前後ボタンを付けるなら、1枚ずつ見せる形(flex: 0 0 100%)にするほうが素直です。

スクロールバーを消さない

scrollbar-width: none は魅力的に見えますが、横に動かせること自体が伝わらなくなります

消してよいのは、サムネイルやドットで位置を別途示している場合だけです。何の手がかりも無いまま消すと、利用者はそこに続きがあることに気づきません。

現在位置は計算しない

scrollLeft を1枚の幅で割れば現在位置が出る、と考えたくなります。動きはしますが、余白・端末ごとの端数処理・ズーム時の丸めでずれます。

IntersectionObserverroot にレールを渡せば、「いまどれが見えているか」をブラウザが答えます。計算も補正も要らず、コードも短くなります。

ただし「最初に交差した要素」を採ってはいけません。2枚が同時に見えている瞬間に右側を拾い、表示が1つ先へずれます。threshold[0, 0.25, 0.5, 0.75, 1] のように段階で渡し、見えている面積がいちばん大きい要素を選んでください。同率なら先頭側を採ります。

そして現在地は aria-current に持ち、見た目はその属性から CSS で描いてください。状態をクラスと変数の両方に持つと、必ずどこかでずれます。

スクロール位置を割り算で求めるとずれる。見えている面積が最大の要素をブラウザに答えさせる 計算で出す scrollLeft ÷ 1枚の幅 356 ÷ 340 = 1.047… 余白・端数・ズームでずれる ブラウザに聞く IntersectionObserver 見えている面積が最大のもの 計算も補正も要らない 1.0 0.9 同時に見えているときは 面積が大きいほう=先頭側を採る
「最初に交差した要素」を採ると、2枚が同時に見えている瞬間に右側を拾って表示が1つ先へずれます。threshold を段階で渡し、面積が最大のものを選ぶのが正解で、同率なら先頭側を採ります。

自動送りには止める条件が4つある

止める必要があるのは、次の4つです。

  1. ポインタが乗っている間 — 読もうとしている
  2. 中にフォーカスがある間 — キーボードで読んでいる
  3. タブが裏に回っている間 — 見えていないものを送っても意味がなく、戻ったとき何枚も飛んでいる
  4. 停止ボタンを押されたとき — WCAG 2.2.2 が5秒以上続く自動的な動きに一時停止の手段を求めています

ここで大事なのは、真偽値1つで管理しないことです。paused = true/false にすると、フォーカスしたままポインタを外した瞬間に再開してしまいます。停止理由を集合(Set)で持ち、空になったときだけ再開してください。

そして利用者が自分でドットや矢印キーを操作したら、以降は自動で動かさないでください。操作した人には意思があります。

止める理由を集合で持ち、空になったときだけ自動送りを再開する ポインタが乗る hover 中にフォーカス focus タブが裏に回る hidden 本人が操作 user holds = Set 空になったときだけ再開する
4つのうち2つが同時に成り立つ場面は普通にあります。1つ消えただけで動き出さないことが、この持ち方の要点です。条件を足すときも、集合に名前を1つ増やすだけで済みます。

キーボードから抜け出せるようにする

スクロールできる領域には tabindex="0" を付けて、左右キーで動かせるようにします。Chrome はスクロール領域を自動で focusable にしますが、他のブラウザは付けないと操作できません。

このとき role="region" は当てないでください。<ul> の list 役割を上書きしてしまい、「4件のリスト」という情報が消えます。名前は aria-label だけで足ります。

ドットの入れ物を role="tablist" にするのもやめてください。tabtabpanel の対応が無いのに role だけ付けると、読み上げの説明と実際の挙動が食い違います。

使いどころとつまずきどころ

向いている場面

  • 写真や実績を横に流して見せる一覧
  • スマートフォンで縦に長くしたくない並び
  • 商品画像とサムネイルの連動
  • お客様の声を数件ずつ見せる区画

つまずきやすい点

  • 自動送りは読んでいる途中で流れる。止める手段が要る
  • スクロールできることが見た目で伝わらないと気づかれない
  • キーボードとスクリーンリーダーでの移動手段を忘れやすい
  • 重要な情報を2枚目以降に置くと、ほとんど読まれない
  • 1画面に複数枚入るレールは、末尾の要素に到達できなくなる

よくある質問

カルーセルは使わないほうがいいと聞きました。

「1枚目以外はほとんど見られない」という点は事実として受け止めてください。伝えたい情報が複数あるなら、縦に並べるほうが確実に読まれます。カルーセルが向くのは、写真のように「同じ種類のものを何点か見せる」場合です。逆に、性質の違う訴求を順番に流すのは向きません。

なぜライブラリを使わないのですか。

いま自分で書く部分が少ないからです。かつてライブラリが引き受けていた仕事のうち、スワイプの慣性、位置の吸い付き、リサイズへの追随は scroll-snap がブラウザ側で済ませます。残るのは現在位置の表示と停止条件だけで、これは50行ほどです。数十KBの依存を足して、更新に追随し続ける価値があるかを比べてください。

スクロールバーを消してもいいですか。

主役の写真のように、別途サムネイルやドットで位置を示している場合だけにしてください。何の手がかりも無い状態でスクロールバーを消すと、横に動かせること自体に気づかれません。「見た目がすっきりする」は、操作できると分からないことの代償に見合いません。

現在位置を scrollLeft から計算してはいけませんか。

動きはしますが、余白・端末ごとの端数処理・ズーム時の丸めでずれます。IntersectionObserver なら「実際に見えているか」をブラウザが答えるので、計算も補正も要りません。コード量もこちらのほうが短くなります。

自動送りはどのくらいの間隔が適切ですか。

間隔を調整する前に、本当に自動で送る必要があるかを疑ってください。必要だと結論が出たなら、1枚を読み切れる時間(短い見出しでも4〜6秒)を確保し、そのうえで必ず停止手段を付けます。WCAG 2.2.2 は5秒以上続く自動的な動きに一時停止の手段を求めています。

スワイプの実装は自分で書かなくてよいのですか。

不要です。overflow-x: auto のスクロールに、端末ごとの慣性やゴムのような跳ね返りがすでに実装されています。touchstart と touchmove から自前で作ると、その端末らしい操作感を再現しきれず、かえって使いにくくなります。

同じ技術を使った完成例を、画面の型ごとにまとめています。作りたい画面が決まっているなら、こちらから探すほうが早いはずです。

ほかの技術