scroll-snapギャラリー

CSS scroll-snapで各スライドにピタッと吸着する横スワイプギャラリー。ドット連動で現在位置も表示します。

#css#scroll-snap#gallery

ライブデモ

使用例(お題: カフェ MOON BREW)

この技法を「カフェ MOON BREW」というテーマのダミーサイトで実際に使った例です。

HTML
<!-- MOON BREW 店内ギャラリー。横スワイプで各カットにピタッと吸着 -->
<div class="mbg-frame">
  <header class="mbg-top">
    <span class="mbg-logo">MOON BREW</span>
    <span class="mbg-label">店内のひととき</span>
  </header>

  <!-- scroll-snapで吸着する横スクロールトラック -->
  <div class="mbg-track" id="snapTrack">
    <figure class="mbg-slide" style="--seed:21">
      <figcaption>
        <span class="mbg-cap-kicker">CAFE</span>
        <h2>窓際のカウンター席</h2>
        <p>午後の光が差し込む特等席。一杯と本を持って。</p>
      </figcaption>
    </figure>
    <figure class="mbg-slide" style="--seed:22">
      <figcaption>
        <span class="mbg-cap-kicker">BAR</span>
        <h2>焙煎機のあるバー</h2>
        <p>香りの源、自家焙煎の小さな焙煎機を眺めながら。</p>
      </figcaption>
    </figure>
    <figure class="mbg-slide" style="--seed:23">
      <figcaption>
        <span class="mbg-cap-kicker">SWEETS</span>
        <h2>本日の焼き菓子</h2>
        <p>バターたっぷりのスコーンとガトーショコラ。</p>
      </figcaption>
    </figure>
    <figure class="mbg-slide" style="--seed:24">
      <figcaption>
        <span class="mbg-cap-kicker">TERRACE</span>
        <h2>緑のテラス席</h2>
        <p>晴れた日はこちらで。風と一緒にコーヒーをどうぞ。</p>
      </figcaption>
    </figure>
  </div>

  <!-- 現在位置を示すドット -->
  <div class="mbg-dots" id="snapDots"></div>
</div>
CSS
* { margin: 0; padding: 0; box-sizing: border-box; }

:root {
  --cream: #f5ede1;
  --brown: #2b1d12;
  --amber: #c98a3b;
}

body {
  font-family: "Hiragino Mincho ProN", "Yu Mincho", "Segoe UI", serif;
  background: var(--brown);
  color: var(--cream);
}

.mbg-frame {
  position: relative;
  width: 100%;
  height: 100vh;
  max-height: 100%;
  display: flex;
  flex-direction: column;
  background: var(--brown);
}

/* 上部バー */
.mbg-top {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 14px 20px;
}
.mbg-logo { font-size: 1rem; letter-spacing: .22em; font-weight: 700; color: var(--cream); }
.mbg-label { font-size: .72rem; color: var(--amber); letter-spacing: .14em; }

/* 横スクロールトラック:scroll-snapで吸着 */
.mbg-track {
  flex: 1 1 auto;
  display: flex;
  overflow-x: auto;
  overflow-y: hidden;
  scroll-snap-type: x mandatory;
  scrollbar-width: none;
  -webkit-overflow-scrolling: touch;
}
.mbg-track::-webkit-scrollbar { display: none; }

/* 各スライド:枠いっぱい+写真背景。--seedで写真を変える */
.mbg-slide {
  position: relative;
  flex: 0 0 100%;
  width: 100%;
  scroll-snap-align: center;
  display: flex;
  align-items: flex-end;
  background-size: cover;
  background-position: center;
  background-color: #3a2818; /* 読み込み前のフォールバック */
}
.mbg-slide::before {
  content: "";
  position: absolute; inset: 0;
  background: linear-gradient(to top, rgba(43,29,18,.85) 8%, rgba(43,29,18,.05) 55%);
}
.mbg-slide figcaption {
  position: relative;
  padding: 22px 26px 30px;
}
.mbg-cap-kicker {
  display: inline-block;
  font-family: "Segoe UI", sans-serif;
  font-size: .6rem;
  letter-spacing: .3em;
  color: var(--amber);
  margin-bottom: 6px;
}
.mbg-slide h2 { font-size: 1.4rem; font-weight: 700; letter-spacing: .04em; }
.mbg-slide p { font-size: .82rem; line-height: 1.7; color: #e8dcc9; margin-top: 6px; max-width: 30ch; }

/* ドット */
.mbg-dots {
  flex: 0 0 auto;
  display: flex;
  gap: 8px;
  justify-content: center;
  padding: 14px 0 18px;
}
.snap-dot {
  width: 8px; height: 8px;
  padding: 0;
  border: none;
  border-radius: 50%;
  background: rgba(245,237,225,.3);
  cursor: pointer;
  transition: background .25s, transform .25s;
}
.snap-dot.active { background: var(--amber); transform: scale(1.35); }
JavaScript
// MOON BREW 店内ギャラリー:scroll-snapの吸着 + ドット連動
(() => {
  const track = document.getElementById('snapTrack');
  const dotsWrap = document.getElementById('snapDots');
  if (!track || !dotsWrap) return; // null安全

  const slides = Array.from(track.children);
  const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  // 各スライドに picsum の写真を設定(--seedを利用)
  slides.forEach(slide => {
    const seed = getComputedStyle(slide).getPropertyValue('--seed').trim() || '1';
    slide.style.backgroundImage = `url("https://picsum.photos/640/420?random=${seed}")`;
  });

  // スライド数だけドットを生成
  const dots = slides.map((_, i) => {
    const d = document.createElement('button');
    d.className = 'snap-dot' + (i === 0 ? ' active' : '');
    d.type = 'button';
    d.setAttribute('aria-label', `${i + 1}枚目へ`);
    // ドットクリックで該当スライドへスクロール
    d.addEventListener('click', () => {
      track.scrollTo({ left: track.clientWidth * i, behavior: 'smooth' });
    });
    dotsWrap.appendChild(d);
    return d;
  });

  // スクロール位置から現在のスライドを算出してドットを更新
  let ticking = false;
  track.addEventListener('scroll', () => {
    if (ticking) return;
    ticking = true;
    requestAnimationFrame(() => {
      const idx = Math.round(track.scrollLeft / track.clientWidth);
      dots.forEach((d, i) => d.classList.toggle('active', i === idx));
      ticking = false;
    });
  }, { passive: true });

  // 操作がなくても吸着が伝わるよう一定間隔で自動送り(操作で停止)
  let auto = !reduce;
  const stopAuto = () => { auto = false; };
  ['pointerdown', 'wheel', 'touchstart'].forEach(ev =>
    track.addEventListener(ev, stopAuto, { passive: true }));

  if (auto) {
    setInterval(() => {
      if (!auto) return;
      const cur = Math.round(track.scrollLeft / track.clientWidth);
      const next = (cur + 1) % slides.length;
      track.scrollTo({ left: track.clientWidth * next, behavior: 'smooth' });
    }, 2400);
  }
})();

コード

HTML
<div class="snap-stage">
  <p class="snap-hint">&#8594; 横にスワイプ / ドラッグ</p>

  <!-- scroll-snap-typeで各スライドにピタッと吸着 -->
  <div class="snap-track" id="snapTrack">
    <article class="snap-slide" style="--c1:#ff7e5f;--c2:#feb47b;">
      <span class="snap-idx">01</span><h2>Sunrise</h2>
    </article>
    <article class="snap-slide" style="--c1:#6a11cb;--c2:#2575fc;">
      <span class="snap-idx">02</span><h2>Twilight</h2>
    </article>
    <article class="snap-slide" style="--c1:#11998e;--c2:#38ef7d;">
      <span class="snap-idx">03</span><h2>Forest</h2>
    </article>
    <article class="snap-slide" style="--c1:#ee0979;--c2:#ff6a00;">
      <span class="snap-idx">04</span><h2>Magma</h2>
    </article>
    <article class="snap-slide" style="--c1:#373b44;--c2:#4286f4;">
      <span class="snap-idx">05</span><h2>Steel</h2>
    </article>
  </div>

  <!-- 現在位置を示すドット -->
  <div class="snap-dots" id="snapDots" aria-hidden="true"></div>
</div>
CSS
* { margin: 0; padding: 0; box-sizing: border-box; }

body {
  font-family: "Segoe UI", system-ui, sans-serif;
  background: #14151c;
  color: #fff;
  overflow: hidden;
}

.snap-stage {
  position: relative;
  height: 100vh;
  min-height: 360px;
  display: flex;
  flex-direction: column;
}

.snap-hint {
  position: absolute;
  top: 14px; left: 50%;
  transform: translateX(-50%);
  z-index: 5;
  font-size: .8rem;
  letter-spacing: .08em;
  padding: 6px 14px;
  border-radius: 999px;
  background: rgba(0,0,0,.35);
  backdrop-filter: blur(4px);
  pointer-events: none;
}

/* 横スクロール+スナップのトラック */
.snap-track {
  flex: 1;
  display: flex;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-behavior: smooth;
  -webkit-overflow-scrolling: touch;
  scrollbar-width: none;
}
.snap-track::-webkit-scrollbar { display: none; }

.snap-slide {
  position: relative;
  flex: 0 0 100%;
  scroll-snap-align: center;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  background: linear-gradient(135deg, var(--c1), var(--c2));
}
.snap-idx {
  font-size: .9rem;
  letter-spacing: .4em;
  opacity: .85;
  margin-bottom: 6px;
}
.snap-slide h2 {
  font-size: 2.6rem;
  font-weight: 800;
  text-shadow: 0 4px 24px rgba(0,0,0,.25);
}

/* ドットインジケーター */
.snap-dots {
  position: absolute;
  bottom: 16px; left: 50%;
  transform: translateX(-50%);
  display: flex;
  gap: 9px;
  z-index: 5;
}
.snap-dot {
  width: 9px; height: 9px;
  border-radius: 50%;
  background: rgba(255,255,255,.4);
  transition: transform .3s ease, background .3s ease;
}
.snap-dot.active {
  background: #fff;
  transform: scale(1.5);
}
JavaScript
const track = document.getElementById('snapTrack');
const dotsWrap = document.getElementById('snapDots');

if (track && dotsWrap) {
  const slides = Array.from(track.children);
  const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  // スライド数だけドットを生成
  const dots = slides.map((_, i) => {
    const d = document.createElement('button');
    d.className = 'snap-dot' + (i === 0 ? ' active' : '');
    d.type = 'button';
    // ドットクリックで該当スライドへスクロール
    d.addEventListener('click', () => {
      track.scrollTo({ left: track.clientWidth * i, behavior: 'smooth' });
    });
    dotsWrap.appendChild(d);
    return d;
  });

  // スクロール位置から現在のスライドを算出してドットを更新
  let ticking = false;
  track.addEventListener('scroll', () => {
    if (ticking) return;
    ticking = true;
    requestAnimationFrame(() => {
      const idx = Math.round(track.scrollLeft / track.clientWidth);
      dots.forEach((d, i) => d.classList.toggle('active', i === idx));
      ticking = false;
    });
  });

  // 操作がなくても吸着が伝わるよう一定間隔で自動送り(操作で停止)
  let auto = !reduce;
  const stopAuto = () => { auto = false; };
  ['pointerdown', 'wheel', 'touchstart'].forEach(ev =>
    track.addEventListener(ev, stopAuto, { passive: true }));

  if (auto) {
    setInterval(() => {
      if (!auto) return;
      const cur = Math.round(track.scrollLeft / track.clientWidth);
      const next = (cur + 1) % slides.length;
      track.scrollTo({ left: track.clientWidth * next, behavior: 'smooth' });
    }, 2200);
  }
}

🤖 AIエージェント用プロンプト

このままコピーしてAIに貼り付け「追加する場所」だけ書き換えればOK
あなたは熟練のフロントエンドエンジニアです。私のWebサイトに「scroll-snapギャラリー」の効果を追加してください。

# 追加してほしい効果
scroll-snapギャラリー(スクロール演出)
CSS scroll-snapで各スライドにピタッと吸着する横スワイプギャラリー。ドット連動で現在位置も表示します。

# 追加する場所
👉【ここに対象箇所を記入:例「トップのヒーローセクション」「お問い合わせボタン」「記事カードの一覧」など】

# 参考実装(この見た目・挙動を再現してください)
【HTML】
<div class="snap-stage">
  <p class="snap-hint">&#8594; 横にスワイプ / ドラッグ</p>

  <!-- scroll-snap-typeで各スライドにピタッと吸着 -->
  <div class="snap-track" id="snapTrack">
    <article class="snap-slide" style="--c1:#ff7e5f;--c2:#feb47b;">
      <span class="snap-idx">01</span><h2>Sunrise</h2>
    </article>
    <article class="snap-slide" style="--c1:#6a11cb;--c2:#2575fc;">
      <span class="snap-idx">02</span><h2>Twilight</h2>
    </article>
    <article class="snap-slide" style="--c1:#11998e;--c2:#38ef7d;">
      <span class="snap-idx">03</span><h2>Forest</h2>
    </article>
    <article class="snap-slide" style="--c1:#ee0979;--c2:#ff6a00;">
      <span class="snap-idx">04</span><h2>Magma</h2>
    </article>
    <article class="snap-slide" style="--c1:#373b44;--c2:#4286f4;">
      <span class="snap-idx">05</span><h2>Steel</h2>
    </article>
  </div>

  <!-- 現在位置を示すドット -->
  <div class="snap-dots" id="snapDots" aria-hidden="true"></div>
</div>

【CSS】
* { margin: 0; padding: 0; box-sizing: border-box; }

body {
  font-family: "Segoe UI", system-ui, sans-serif;
  background: #14151c;
  color: #fff;
  overflow: hidden;
}

.snap-stage {
  position: relative;
  height: 100vh;
  min-height: 360px;
  display: flex;
  flex-direction: column;
}

.snap-hint {
  position: absolute;
  top: 14px; left: 50%;
  transform: translateX(-50%);
  z-index: 5;
  font-size: .8rem;
  letter-spacing: .08em;
  padding: 6px 14px;
  border-radius: 999px;
  background: rgba(0,0,0,.35);
  backdrop-filter: blur(4px);
  pointer-events: none;
}

/* 横スクロール+スナップのトラック */
.snap-track {
  flex: 1;
  display: flex;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-behavior: smooth;
  -webkit-overflow-scrolling: touch;
  scrollbar-width: none;
}
.snap-track::-webkit-scrollbar { display: none; }

.snap-slide {
  position: relative;
  flex: 0 0 100%;
  scroll-snap-align: center;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  background: linear-gradient(135deg, var(--c1), var(--c2));
}
.snap-idx {
  font-size: .9rem;
  letter-spacing: .4em;
  opacity: .85;
  margin-bottom: 6px;
}
.snap-slide h2 {
  font-size: 2.6rem;
  font-weight: 800;
  text-shadow: 0 4px 24px rgba(0,0,0,.25);
}

/* ドットインジケーター */
.snap-dots {
  position: absolute;
  bottom: 16px; left: 50%;
  transform: translateX(-50%);
  display: flex;
  gap: 9px;
  z-index: 5;
}
.snap-dot {
  width: 9px; height: 9px;
  border-radius: 50%;
  background: rgba(255,255,255,.4);
  transition: transform .3s ease, background .3s ease;
}
.snap-dot.active {
  background: #fff;
  transform: scale(1.5);
}

【JavaScript】
const track = document.getElementById('snapTrack');
const dotsWrap = document.getElementById('snapDots');

if (track && dotsWrap) {
  const slides = Array.from(track.children);
  const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  // スライド数だけドットを生成
  const dots = slides.map((_, i) => {
    const d = document.createElement('button');
    d.className = 'snap-dot' + (i === 0 ? ' active' : '');
    d.type = 'button';
    // ドットクリックで該当スライドへスクロール
    d.addEventListener('click', () => {
      track.scrollTo({ left: track.clientWidth * i, behavior: 'smooth' });
    });
    dotsWrap.appendChild(d);
    return d;
  });

  // スクロール位置から現在のスライドを算出してドットを更新
  let ticking = false;
  track.addEventListener('scroll', () => {
    if (ticking) return;
    ticking = true;
    requestAnimationFrame(() => {
      const idx = Math.round(track.scrollLeft / track.clientWidth);
      dots.forEach((d, i) => d.classList.toggle('active', i === idx));
      ticking = false;
    });
  });

  // 操作がなくても吸着が伝わるよう一定間隔で自動送り(操作で停止)
  let auto = !reduce;
  const stopAuto = () => { auto = false; };
  ['pointerdown', 'wheel', 'touchstart'].forEach(ev =>
    track.addEventListener(ev, stopAuto, { passive: true }));

  if (auto) {
    setInterval(() => {
      if (!auto) return;
      const cur = Math.round(track.scrollLeft / track.clientWidth);
      const next = (cur + 1) % slides.length;
      track.scrollTo({ left: track.clientWidth * next, behavior: 'smooth' });
    }, 2200);
  }
}

# 外部ライブラリ
なし(追加ライブラリ不要)

# 守ってほしいこと
- 既存のHTML構造・レイアウト・デザインを壊さないこと。必要に応じてクラス名・色・サイズを私のサイトに合わせて調整してよい。
- クラス名やidが既存と衝突しないよう、必要なら接頭辞で名前空間を分けること。
- レスポンシブ対応と prefers-reduced-motion への配慮を入れること。
- 私のサイトのフレームワーク(React / Vue / 素のHTML など)に合わせて実装すること。不明な場合は素のHTML/CSS/JSで提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。