ドラッグ探索ギャラリー

画像タイルの帯をPointer Eventsで横にドラッグして探索するギャラリー。待機中はゆっくり自動で流れ、離すと慣性で滑り、端ではゆるく戻ります。作品集や横長の回遊コンテンツに使えます。

#ui#drag#inertia#gallery#pointer

ライブデモ

使用例(お題: アイドルグループ 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で提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。