ドラッグ探索ギャラリー
画像タイルの帯をPointer Eventsで横にドラッグして探索するギャラリー。待機中はゆっくり自動で流れ、離すと慣性で滑り、端ではゆるく戻ります。作品集や横長の回遊コンテンツに使えます。
ライブデモ
使用例(お題: アイドルグループ Sakura)
この技法を「アイドルグループ Sakura」というテーマのダミーサイトで実際に使った例です。
HTML
<!-- Sakura:ライブフォトをドラッグ探索するギャラリー -->
<div class="idol">
<div class="idol__bar">
<span class="idol__logo">🌸 Sakura</span>
<span class="idol__sub">LIVE PHOTO GALLERY</span>
</div>
<div class="dg">
<p class="dg__hint">ドラッグして探索(待機中は自動で流れます)</p>
<div class="dg__viewport" data-viewport>
<div class="dg__track" data-track>
<!-- タイルは JS で生成・複製 -->
</div>
<span class="dg__edge dg__edge--l" aria-hidden="true"></span>
<span class="dg__edge dg__edge--r" aria-hidden="true"></span>
</div>
</div>
</div>
CSS
/* Sakura アイドル テーマ */
:root{--pink:#ffd1e0;--deep:#e86a96;--ink:#4a3540;--muted:#9b8690;--line:#f0dde4;--paper:#fff}
*{box-sizing:border-box}
body{
margin:0;min-height:100vh;display:grid;place-items:center;padding:14px;
font-family:"Hiragino Kaku Gothic ProN","Segoe UI",sans-serif;color:var(--ink);
background:radial-gradient(600px 320px at 50% -10%,#ffe3ee,transparent),#fff5f9;
}
.idol{width:min(560px,100%)}
.idol__bar{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}
.idol__logo{font-weight:800;color:var(--deep)}
.idol__sub{font-size:.72rem;letter-spacing:.12em;color:var(--muted)}
.dg{text-align:center}
.dg__hint{margin:0 0 12px;color:var(--muted);font-size:.8rem}
/* 帯の表示窓 */
.dg__viewport{position:relative;overflow:hidden;border-radius:18px;border:1px solid var(--line);background:#fff;box-shadow:0 16px 36px -20px rgba(232,106,150,.5);cursor:grab;touch-action:pan-y;user-select:none}
.dg__viewport.is-drag{cursor:grabbing}
/* 横並びの帯 */
.dg__track{display:flex;gap:14px;padding:16px;will-change:transform}
/* 画像タイル */
.dg__tile{position:relative;flex:0 0 auto;width:172px;height:200px;border-radius:14px;overflow:hidden;border:3px solid #fff;box-shadow:0 6px 16px rgba(232,106,150,.18);background:var(--pink)}
.dg__tile img{width:100%;height:100%;object-fit:cover;display:block;pointer-events:none;-webkit-user-drag:none;user-select:none;transition:transform .4s ease}
.dg__viewport:hover .dg__tile img{transform:scale(1.05)}
.dg__tile__cap{position:absolute;left:0;right:0;bottom:0;padding:9px 11px;text-align:left;font-size:.76rem;font-weight:700;color:#fff;background:linear-gradient(transparent,rgba(74,53,64,.7))}
.dg__tile__tag{display:inline-block;font-size:.6rem;font-weight:800;color:var(--deep);background:var(--pink);padding:1px 7px;border-radius:999px;margin-bottom:4px}
/* 端のフェード */
.dg__edge{position:absolute;top:0;bottom:0;width:46px;pointer-events:none;z-index:2}
.dg__edge--l{left:0;background:linear-gradient(90deg,#fff,transparent)}
.dg__edge--r{right:0;background:linear-gradient(270deg,#fff,transparent)}
@media (prefers-reduced-motion:reduce){.dg__tile img{transition:none}}
JavaScript
// ドラッグ探索ギャラリー:Pointer Events で横ドラッグ。離すと慣性で滑り、端でゆるく戻る。
// 待機中はゆっくり自動で流れ、操作したら手動へ切替。
const viewport = document.querySelector('[data-viewport]');
const track = document.querySelector('[data-track]');
if (viewport && track) {
// ライブ写真の素材(タグ+キャプション。picsum を循環使用)
const ITEMS = [
{ tag: 'TOUR', cap: '春の全国ツアー 横浜公演' },
{ tag: 'MV', cap: '「春風センセーション」撮影' },
{ tag: 'FES', cap: '夏フェス メインステージ' },
{ tag: 'BACK', cap: '本番前の楽屋にて' },
{ tag: 'LIVE', cap: 'アンコールの一枚' },
{ tag: 'EVENT', cap: 'ファンミーティング' },
];
const COUNT = ITEMS.length;
// タイルを生成(帯を複製して継ぎ目のない流れにする)
const makeTile = (i) => {
const d = ITEMS[i % COUNT];
const tile = document.createElement('figure');
tile.className = 'dg__tile';
const img = document.createElement('img');
img.src = `https://picsum.photos/344/400?random=${60 + (i % COUNT)}`;
img.alt = '';
img.loading = 'lazy';
const cap = document.createElement('figcaption');
cap.className = 'dg__tile__cap';
cap.innerHTML = `<span class="dg__tile__tag">${d.tag}</span><br>`;
cap.appendChild(document.createTextNode(d.cap)); // 文字列は安全に挿入
tile.appendChild(img);
tile.appendChild(cap);
return tile;
};
// 実体セット+複製セットで無限ループ風に
for (let i = 0; i < COUNT; i++) track.appendChild(makeTile(i));
const firstSet = [...track.children];
firstSet.forEach((el) => track.appendChild(el.cloneNode(true)));
// 1セットの幅を測る(ラップ境界)
let setWidth = 0;
const measure = () => {
const cloneFirst = track.children[COUNT];
if (cloneFirst) setWidth = cloneFirst.offsetLeft - track.children[0].offsetLeft;
};
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let pos = 0; // 現在の translateX
let velocity = 0; // 慣性速度(px/frame)
const AUTO = reduce ? 0 : 0.4; // 待機時の自動スクロール
const FRICTION = 0.94;
let drag = null;
let raf = 0;
// 位置をセット幅でラップ
const wrap = () => {
if (setWidth <= 0) return;
if (pos <= -setWidth) pos += setWidth;
else if (pos > 0) pos -= setWidth;
};
const render = () => { track.style.transform = `translate3d(${pos}px,0,0)`; };
// 毎フレーム更新
const tick = () => {
if (drag) {
// ドラッグ中は描画のみ
} else if (Math.abs(velocity) > 0.05) {
pos += velocity; // 慣性で滑る
velocity *= FRICTION;
wrap();
} else {
velocity = 0;
pos -= AUTO; // 待機:自動で流れる
wrap();
}
render();
raf = requestAnimationFrame(tick);
};
const onDown = (e) => {
drag = { startX: e.clientX, lastX: e.clientX, basePos: pos };
velocity = 0;
viewport.classList.add('is-drag');
viewport.setPointerCapture?.(e.pointerId);
};
const onMove = (e) => {
if (!drag) return;
const dx = e.clientX - drag.startX;
pos = drag.basePos + dx;
velocity = e.clientX - drag.lastX; // 直近の移動量=慣性の初速
drag.lastX = e.clientX;
wrap();
};
const onUp = (e) => {
if (!drag) return;
drag = null;
viewport.classList.remove('is-drag');
viewport.releasePointerCapture?.(e.pointerId);
if (Math.abs(velocity) < 0.5) velocity = 0; // 微速なら自動流れへ
};
viewport.addEventListener('pointerdown', onDown);
viewport.addEventListener('pointermove', onMove);
viewport.addEventListener('pointerup', onUp);
viewport.addEventListener('pointercancel', onUp);
viewport.addEventListener('pointerleave', onUp);
// 画像読込・リサイズで測り直す
const remeasure = () => { measure(); wrap(); };
window.addEventListener('resize', remeasure);
track.querySelectorAll('img').forEach((img) => {
if (img.complete) return;
img.addEventListener('load', remeasure, { once: true });
});
measure();
raf = requestAnimationFrame(tick);
}
コード
HTML
<!-- ドラッグ探索ギャラリー:画像タイルの帯を横へドラッグ。離すと慣性で滑り、端でゆるく戻る -->
<div class="dg">
<p class="dg__hint">ドラッグして探索(待機中は自動で流れます)</p>
<div class="dg__viewport" data-viewport>
<div class="dg__track" data-track>
<!-- タイルは JS で生成・複製 -->
</div>
<span class="dg__edge dg__edge--l" aria-hidden="true"></span>
<span class="dg__edge dg__edge--r" aria-hidden="true"></span>
</div>
</div>
CSS
:root{
--bg:#0d1117;
--text:#e6edf3;
--muted:#8b949e;
--line:#262d38;
}
*{box-sizing:border-box}
body{
margin:0;min-height:100vh;
display:grid;place-items:center;padding:20px;
font-family:"Segoe UI",system-ui,sans-serif;color:var(--text);
background:radial-gradient(700px 400px at 50% -10%,#1c2438,transparent),var(--bg);
}
.dg{width:min(560px,100%);text-align:center}
.dg__hint{margin:0 0 14px;color:var(--muted);font-size:.82rem;letter-spacing:.02em}
/* 帯の表示窓(中央配置) */
.dg__viewport{
position:relative;overflow:hidden;border-radius:16px;
border:1px solid var(--line);
background:#11161f;
box-shadow:0 20px 40px -22px rgba(0,0,0,.8);
cursor:grab;touch-action:pan-y;user-select:none;
}
.dg__viewport.is-drag{cursor:grabbing}
/* 横並びの帯。translateX は JS が更新 */
.dg__track{
display:flex;gap:14px;padding:16px;
will-change:transform;
}
/* 画像タイル */
.dg__tile{
position:relative;flex:0 0 auto;
width:180px;height:200px;border-radius:13px;overflow:hidden;
border:1px solid var(--line);background:#1b2230;
}
.dg__tile img{
width:100%;height:100%;object-fit:cover;display:block;
pointer-events:none;-webkit-user-drag:none;user-select:none;
transition:transform .4s ease;
}
.dg__viewport:hover .dg__tile img{transform:scale(1.04)}
.dg__tile__cap{
position:absolute;left:0;right:0;bottom:0;
padding:10px 12px;text-align:left;
font-size:.78rem;font-weight:600;color:#fff;
background:linear-gradient(transparent,rgba(0,0,0,.65));
}
/* 端を示すフェード */
.dg__edge{
position:absolute;top:0;bottom:0;width:46px;pointer-events:none;z-index:2;
}
.dg__edge--l{left:0;background:linear-gradient(90deg,#11161f,transparent)}
.dg__edge--r{right:0;background:linear-gradient(270deg,#11161f,transparent)}
@media (prefers-reduced-motion:reduce){
.dg__tile img{transition:none}
}
JavaScript
// ドラッグ探索ギャラリー:Pointer Events で横ドラッグ。離すと慣性で滑り、端でゆるく戻る。
// 待機中はゆっくり自動で流れ、操作したら手動へ切替。
const viewport = document.querySelector('[data-viewport]');
const track = document.querySelector('[data-track]');
if (viewport && track) {
// タイル素材(picsum を循環使用)
const CAPTIONS = ['朝の海岸', '路地の灯り', '霧の山道', '都市の夜景', '砂丘の稜線', '森のトンネル'];
const COUNT = CAPTIONS.length;
// タイルを生成(後で帯を複製して継ぎ目のない流れにする)
const makeTile = (i) => {
const tile = document.createElement('figure');
tile.className = 'dg__tile';
const img = document.createElement('img');
img.src = `https://picsum.photos/360/400?random=${(i % COUNT) + 1}`;
img.alt = '';
img.loading = 'lazy';
const cap = document.createElement('figcaption');
cap.className = 'dg__tile__cap';
cap.textContent = CAPTIONS[i % COUNT];
tile.appendChild(img);
tile.appendChild(cap);
return tile;
};
// 1セット分を生成し、半分が「実体」、もう半分が継ぎ目用の複製
for (let i = 0; i < COUNT; i++) track.appendChild(makeTile(i));
const firstSet = [...track.children];
firstSet.forEach((el) => track.appendChild(el.cloneNode(true)));
// 1セットの幅(実体分)。これを境界に位置をラップして無限ループ風に見せる
let setWidth = 0;
const measure = () => {
// gap を含む実体セットの幅 = 複製先頭の left - 実体先頭の left
const cloneFirst = track.children[COUNT];
if (cloneFirst) setWidth = cloneFirst.offsetLeft - track.children[0].offsetLeft;
};
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let pos = 0; // 現在の translateX(負方向に進む)
let velocity = 0; // 慣性速度(px/frame)
const AUTO = reduce ? 0 : 0.4; // 待機時の自動スクロール速度(px/frame)
const FRICTION = 0.94; // 慣性の減衰
let drag = null; // ドラッグ状態
let raf = 0;
// 位置をセット幅でラップ(端の無限ループ化)
const wrap = () => {
if (setWidth <= 0) return;
if (pos <= -setWidth) pos += setWidth;
else if (pos > 0) pos -= setWidth;
};
const render = () => { track.style.transform = `translate3d(${pos}px,0,0)`; };
// 毎フレーム更新
const tick = () => {
if (drag) {
// ドラッグ中はポインタ追従(tick では描画のみ)
} else if (Math.abs(velocity) > 0.05) {
// 慣性で滑る
pos += velocity;
velocity *= FRICTION;
wrap();
} else {
// 待機:ゆっくり自動で流れる
velocity = 0;
pos -= AUTO;
wrap();
}
render();
raf = requestAnimationFrame(tick);
};
// ドラッグ開始
const onDown = (e) => {
drag = { startX: e.clientX, lastX: e.clientX, basePos: pos };
velocity = 0;
viewport.classList.add('is-drag');
viewport.setPointerCapture?.(e.pointerId);
};
// ドラッグ中:移動量を位置へ反映し、速度を推定
const onMove = (e) => {
if (!drag) return;
const dx = e.clientX - drag.startX;
pos = drag.basePos + dx;
velocity = e.clientX - drag.lastX; // 直近フレームの移動量=慣性の初速
drag.lastX = e.clientX;
wrap();
};
// ドラッグ終了:慣性へ引き継ぎ
const onUp = (e) => {
if (!drag) return;
drag = null;
viewport.classList.remove('is-drag');
viewport.releasePointerCapture?.(e.pointerId);
// 速度が小さければ自動流れに任せる
if (Math.abs(velocity) < 0.5) velocity = 0;
};
viewport.addEventListener('pointerdown', onDown);
viewport.addEventListener('pointermove', onMove);
viewport.addEventListener('pointerup', onUp);
viewport.addEventListener('pointercancel', onUp);
viewport.addEventListener('pointerleave', onUp);
// 画像読込で幅が変わるため、ロード時とリサイズで測り直す
const remeasure = () => { measure(); wrap(); };
window.addEventListener('resize', remeasure);
track.querySelectorAll('img').forEach((img) => {
if (img.complete) return;
img.addEventListener('load', remeasure, { once: true });
});
measure();
raf = requestAnimationFrame(tick);
}
🤖 AIエージェント用プロンプト
このままコピーしてAIに貼り付け「追加する場所」だけ書き換えればOK
あなたは熟練のフロントエンドエンジニアです。私のWebサイトに「ドラッグ探索ギャラリー」の効果を追加してください。
# 追加してほしい効果
ドラッグ探索ギャラリー(UIコンポーネント)
画像タイルの帯をPointer Eventsで横にドラッグして探索するギャラリー。待機中はゆっくり自動で流れ、離すと慣性で滑り、端ではゆるく戻ります。作品集や横長の回遊コンテンツに使えます。
# 追加する場所
👉【ここに対象箇所を記入:例「トップのヒーローセクション」「お問い合わせボタン」「記事カードの一覧」など】
# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- ドラッグ探索ギャラリー:画像タイルの帯を横へドラッグ。離すと慣性で滑り、端でゆるく戻る -->
<div class="dg">
<p class="dg__hint">ドラッグして探索(待機中は自動で流れます)</p>
<div class="dg__viewport" data-viewport>
<div class="dg__track" data-track>
<!-- タイルは JS で生成・複製 -->
</div>
<span class="dg__edge dg__edge--l" aria-hidden="true"></span>
<span class="dg__edge dg__edge--r" aria-hidden="true"></span>
</div>
</div>
【CSS】
:root{
--bg:#0d1117;
--text:#e6edf3;
--muted:#8b949e;
--line:#262d38;
}
*{box-sizing:border-box}
body{
margin:0;min-height:100vh;
display:grid;place-items:center;padding:20px;
font-family:"Segoe UI",system-ui,sans-serif;color:var(--text);
background:radial-gradient(700px 400px at 50% -10%,#1c2438,transparent),var(--bg);
}
.dg{width:min(560px,100%);text-align:center}
.dg__hint{margin:0 0 14px;color:var(--muted);font-size:.82rem;letter-spacing:.02em}
/* 帯の表示窓(中央配置) */
.dg__viewport{
position:relative;overflow:hidden;border-radius:16px;
border:1px solid var(--line);
background:#11161f;
box-shadow:0 20px 40px -22px rgba(0,0,0,.8);
cursor:grab;touch-action:pan-y;user-select:none;
}
.dg__viewport.is-drag{cursor:grabbing}
/* 横並びの帯。translateX は JS が更新 */
.dg__track{
display:flex;gap:14px;padding:16px;
will-change:transform;
}
/* 画像タイル */
.dg__tile{
position:relative;flex:0 0 auto;
width:180px;height:200px;border-radius:13px;overflow:hidden;
border:1px solid var(--line);background:#1b2230;
}
.dg__tile img{
width:100%;height:100%;object-fit:cover;display:block;
pointer-events:none;-webkit-user-drag:none;user-select:none;
transition:transform .4s ease;
}
.dg__viewport:hover .dg__tile img{transform:scale(1.04)}
.dg__tile__cap{
position:absolute;left:0;right:0;bottom:0;
padding:10px 12px;text-align:left;
font-size:.78rem;font-weight:600;color:#fff;
background:linear-gradient(transparent,rgba(0,0,0,.65));
}
/* 端を示すフェード */
.dg__edge{
position:absolute;top:0;bottom:0;width:46px;pointer-events:none;z-index:2;
}
.dg__edge--l{left:0;background:linear-gradient(90deg,#11161f,transparent)}
.dg__edge--r{right:0;background:linear-gradient(270deg,#11161f,transparent)}
@media (prefers-reduced-motion:reduce){
.dg__tile img{transition:none}
}
【JavaScript】
// ドラッグ探索ギャラリー:Pointer Events で横ドラッグ。離すと慣性で滑り、端でゆるく戻る。
// 待機中はゆっくり自動で流れ、操作したら手動へ切替。
const viewport = document.querySelector('[data-viewport]');
const track = document.querySelector('[data-track]');
if (viewport && track) {
// タイル素材(picsum を循環使用)
const CAPTIONS = ['朝の海岸', '路地の灯り', '霧の山道', '都市の夜景', '砂丘の稜線', '森のトンネル'];
const COUNT = CAPTIONS.length;
// タイルを生成(後で帯を複製して継ぎ目のない流れにする)
const makeTile = (i) => {
const tile = document.createElement('figure');
tile.className = 'dg__tile';
const img = document.createElement('img');
img.src = `https://picsum.photos/360/400?random=${(i % COUNT) + 1}`;
img.alt = '';
img.loading = 'lazy';
const cap = document.createElement('figcaption');
cap.className = 'dg__tile__cap';
cap.textContent = CAPTIONS[i % COUNT];
tile.appendChild(img);
tile.appendChild(cap);
return tile;
};
// 1セット分を生成し、半分が「実体」、もう半分が継ぎ目用の複製
for (let i = 0; i < COUNT; i++) track.appendChild(makeTile(i));
const firstSet = [...track.children];
firstSet.forEach((el) => track.appendChild(el.cloneNode(true)));
// 1セットの幅(実体分)。これを境界に位置をラップして無限ループ風に見せる
let setWidth = 0;
const measure = () => {
// gap を含む実体セットの幅 = 複製先頭の left - 実体先頭の left
const cloneFirst = track.children[COUNT];
if (cloneFirst) setWidth = cloneFirst.offsetLeft - track.children[0].offsetLeft;
};
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let pos = 0; // 現在の translateX(負方向に進む)
let velocity = 0; // 慣性速度(px/frame)
const AUTO = reduce ? 0 : 0.4; // 待機時の自動スクロール速度(px/frame)
const FRICTION = 0.94; // 慣性の減衰
let drag = null; // ドラッグ状態
let raf = 0;
// 位置をセット幅でラップ(端の無限ループ化)
const wrap = () => {
if (setWidth <= 0) return;
if (pos <= -setWidth) pos += setWidth;
else if (pos > 0) pos -= setWidth;
};
const render = () => { track.style.transform = `translate3d(${pos}px,0,0)`; };
// 毎フレーム更新
const tick = () => {
if (drag) {
// ドラッグ中はポインタ追従(tick では描画のみ)
} else if (Math.abs(velocity) > 0.05) {
// 慣性で滑る
pos += velocity;
velocity *= FRICTION;
wrap();
} else {
// 待機:ゆっくり自動で流れる
velocity = 0;
pos -= AUTO;
wrap();
}
render();
raf = requestAnimationFrame(tick);
};
// ドラッグ開始
const onDown = (e) => {
drag = { startX: e.clientX, lastX: e.clientX, basePos: pos };
velocity = 0;
viewport.classList.add('is-drag');
viewport.setPointerCapture?.(e.pointerId);
};
// ドラッグ中:移動量を位置へ反映し、速度を推定
const onMove = (e) => {
if (!drag) return;
const dx = e.clientX - drag.startX;
pos = drag.basePos + dx;
velocity = e.clientX - drag.lastX; // 直近フレームの移動量=慣性の初速
drag.lastX = e.clientX;
wrap();
};
// ドラッグ終了:慣性へ引き継ぎ
const onUp = (e) => {
if (!drag) return;
drag = null;
viewport.classList.remove('is-drag');
viewport.releasePointerCapture?.(e.pointerId);
// 速度が小さければ自動流れに任せる
if (Math.abs(velocity) < 0.5) velocity = 0;
};
viewport.addEventListener('pointerdown', onDown);
viewport.addEventListener('pointermove', onMove);
viewport.addEventListener('pointerup', onUp);
viewport.addEventListener('pointercancel', onUp);
viewport.addEventListener('pointerleave', onUp);
// 画像読込で幅が変わるため、ロード時とリサイズで測り直す
const remeasure = () => { measure(); wrap(); };
window.addEventListener('resize', remeasure);
track.querySelectorAll('img').forEach((img) => {
if (img.complete) return;
img.addEventListener('load', remeasure, { once: true });
});
measure();
raf = requestAnimationFrame(tick);
}
# 外部ライブラリ
なし(追加ライブラリ不要)
# 守ってほしいこと
- 既存のHTML構造・レイアウト・デザインを壊さないこと。必要に応じてクラス名・色・サイズを私のサイトに合わせて調整してよい。
- クラス名やidが既存と衝突しないよう、必要なら接頭辞で名前空間を分けること。
- レスポンシブ対応と prefers-reduced-motion への配慮を入れること。
- 私のサイトのフレームワーク(React / Vue / 素のHTML など)に合わせて実装すること。不明な場合は素のHTML/CSS/JSで提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。