scroll-snapギャラリー
CSS scroll-snapで各スライドにピタッと吸着する横スワイプギャラリー。ドット連動で現在位置も表示します。
ライブデモ
使用例(お題: カフェ 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">→ 横にスワイプ / ドラッグ</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">→ 横にスワイプ / ドラッグ</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で提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。