スワイプカード(Tinder風)

重なったカードをPointer Eventsでドラッグし、左右に振り切ると回転しながら飛んで消え次が前面に。しきい値未満は元に戻ります。マッチングや選別UIに使えます。

#ui#drag#swipe#pointer

ライブデモ

使用例(お題: アイドルグループ Sakura)

この技法を「アイドルグループ Sakura」というテーマのダミーサイトで実際に使った例です。

HTML
<!-- Sakura:メンバー紹介カードをスワイプして「推し」を選ぶ -->
<div class="idol">
  <div class="idol__bar">
    <span class="idol__logo">🌸 Sakura</span>
    <span class="idol__sub">推しメンを見つけよう</span>
  </div>

  <div class="sc-stage">
    <div class="sc-deck" data-deck>
      <!-- カードは JS で動的生成 -->
    </div>

    <div class="sc-controls">
      <button class="sc-btn sc-btn--no" data-nope aria-label="スキップ">✕</button>
      <p class="sc-status" data-status aria-live="polite">ドラッグして左右に振り分け</p>
      <button class="sc-btn sc-btn--yes" data-like aria-label="推す">♥</button>
    </div>

    <button class="sc-reset" data-reset hidden>もう一度</button>
  </div>
</div>
CSS
/* Sakura アイドル テーマ */
:root{--pink:#ffd1e0;--deep:#e86a96;--ink:#4a3540;--muted:#9b8690;--yes:#e86a96;--no:#9aa4b2;--line:#f0dde4}
*{box-sizing:border-box}
body{
  margin:0;min-height:100vh;position:relative;overflow:hidden;
  display:grid;place-items:start center;padding:14px;
  font-family:"Hiragino Kaku Gothic ProN","Segoe UI",sans-serif;color:var(--ink);
  background:radial-gradient(600px 300px at 50% -10%,#ffe3ee,transparent),#fff5f9;
}
.idol{width:min(320px,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:.74rem;color:var(--muted)}
.sc-stage{text-align:center}
/* カードを重ねるデッキ */
.sc-deck{position:relative;height:250px;width:200px;margin:0 auto 14px}
.sc-card{
  position:absolute;inset:0;border-radius:20px;overflow:hidden;cursor:grab;
  border:3px solid #fff;box-shadow:0 18px 36px -16px rgba(232,106,150,.5);
  color:#fff;user-select:none;touch-action:none;
  display:flex;flex-direction:column;justify-content:flex-end;
  transform:translateY(calc(var(--i,0) * 10px)) scale(calc(1 - var(--i,0) * .05));
  transition:transform .35s cubic-bezier(.2,.9,.3,1.2),opacity .35s;
  background:var(--photo,linear-gradient(160deg,#ffb3cd,#e86a96));
  background-size:cover;background-position:center;
}
.sc-card.is-drag{cursor:grabbing;transition:none}
.sc-card.is-gone{transition:transform .5s ease,opacity .5s ease}
.sc-card__body{padding:14px;background:linear-gradient(transparent,rgba(74,53,64,.7));text-align:left}
.sc-card__emoji{display:inline-block;font-size:.66rem;font-weight:800;letter-spacing:.08em;background:var(--pink);color:var(--deep);padding:2px 9px;border-radius:999px;margin-bottom:6px}
.sc-card__name{margin:0;font-size:1.2rem;font-weight:800}
.sc-card__meta{margin:2px 0 0;font-size:.8rem;opacity:.9}
/* スワイプ方向ラベル */
.sc-card__stamp{position:absolute;top:16px;font-size:1rem;font-weight:800;letter-spacing:.06em;padding:5px 12px;border-radius:9px;border:3px solid currentColor;opacity:0;transition:opacity .12s}
.sc-card__stamp--like{left:12px;color:var(--yes);transform:rotate(-14deg)}
.sc-card__stamp--nope{right:12px;color:var(--no);transform:rotate(14deg)}
.sc-card.show-like .sc-card__stamp--like{opacity:1}
.sc-card.show-nope .sc-card__stamp--nope{opacity:1}
/* 操作ボタン行 */
.sc-controls{display:flex;align-items:center;justify-content:center;gap:12px}
.sc-btn{flex:none;width:46px;height:46px;border-radius:50%;cursor:pointer;font-size:1.1rem;font-weight:700;background:#fff;border:1px solid var(--line);box-shadow:0 4px 12px rgba(232,106,150,.18);transition:transform .15s,filter .2s}
.sc-btn:hover{filter:brightness(1.04)}
.sc-btn:active{transform:scale(.92)}
.sc-btn--no{color:var(--no)}
.sc-btn--yes{color:var(--deep)}
.sc-status{flex:1;margin:0;color:var(--muted);font-size:.78rem;min-width:0}
.sc-reset{margin-top:12px;font:inherit;font-weight:700;cursor:pointer;color:var(--deep);background:#fff;border:1px solid var(--line);border-radius:10px;padding:9px 18px}
.sc-reset[hidden]{display:none}
@media (prefers-reduced-motion:reduce){.sc-card,.sc-card.is-gone{transition:opacity .3s}}
JavaScript
// スワイプカード:メンバー紹介をドラッグで振り分け。しきい値超で飛ばし、未満で戻す
const deck = document.querySelector('[data-deck]');
const statusEl = document.querySelector('[data-status]');
const resetBtn = document.querySelector('[data-reset]');
const likeBtn = document.querySelector('[data-like]');
const nopeBtn = document.querySelector('[data-nope]');

// Sakura メンバー(架空名)。photo は背景画像
const DATA = [
  { tag: 'Center', name: '桜井 ひな', meta: '担当カラー / ピンク', photo: "url('https://picsum.photos/300/360?random=51')" },
  { tag: 'Vocal', name: '月城 あおい', meta: '担当カラー / ブルー', photo: "url('https://picsum.photos/300/360?random=52')" },
  { tag: 'Dance', name: '星野 みらい', meta: '担当カラー / イエロー', photo: "url('https://picsum.photos/300/360?random=53')" },
  { tag: 'Lyric', name: '花咲 ことね', meta: '担当カラー / グリーン', photo: "url('https://picsum.photos/300/360?random=54')" },
  { tag: 'Visual', name: '小鳥遊 すず', meta: '担当カラー / ラベンダー', photo: "url('https://picsum.photos/300/360?random=55')" },
];

if (deck) {
  const THRESHOLD = 90; // 振り切り判定(px)
  let cards = [];
  let drag = null;

  // デッキを生成
  const build = () => {
    deck.innerHTML = '';
    cards = DATA.map((d) => {
      const el = document.createElement('article');
      el.className = 'sc-card';
      el.style.setProperty('--photo', d.photo);
      el.innerHTML =
        '<span class="sc-card__stamp sc-card__stamp--like">推す♥</span>' +
        '<span class="sc-card__stamp sc-card__stamp--nope">スキップ</span>' +
        '<div class="sc-card__body">' +
        '<span class="sc-card__emoji"></span>' +
        '<h3 class="sc-card__name"></h3>' +
        '<p class="sc-card__meta"></p></div>';
      // 文字列は textContent で安全に挿入
      el.querySelector('.sc-card__emoji').textContent = d.tag;
      el.querySelector('.sc-card__name').textContent = d.name;
      el.querySelector('.sc-card__meta').textContent = d.meta;
      el.addEventListener('pointerdown', onDown);
      deck.appendChild(el);
      return el;
    });
    updateStack();
    if (resetBtn) resetBtn.hidden = true;
    setStatus('ドラッグして左右に振り分け');
  };

  // 奥行きを付け直し、最前面だけ操作可能に
  const updateStack = () => {
    const n = cards.length;
    cards.forEach((el, idx) => {
      const depth = n - 1 - idx;
      el.style.setProperty('--i', Math.min(depth, 3));
      el.style.pointerEvents = idx === n - 1 ? 'auto' : 'none';
      el.style.zIndex = String(idx);
    });
  };

  const top = () => cards[cards.length - 1] || null;
  const setStatus = (t) => { if (statusEl) statusEl.textContent = t; };

  // ドラッグ開始
  function onDown(e) {
    const el = top();
    if (!el || e.currentTarget !== el) return;
    drag = { el, startX: e.clientX, startY: e.clientY, dx: 0 };
    el.classList.add('is-drag');
    el.setPointerCapture(e.pointerId);
    el.addEventListener('pointermove', onMove);
    el.addEventListener('pointerup', onUp);
    el.addEventListener('pointercancel', onUp);
  }

  // ドラッグ中:移動+回転+方向ラベル
  function onMove(e) {
    if (!drag) return;
    drag.dx = e.clientX - drag.startX;
    const dy = (e.clientY - drag.startY) * 0.4;
    const rot = drag.dx * 0.06;
    drag.el.style.transform = 'translate(' + drag.dx + 'px,' + dy + 'px) rotate(' + rot + 'deg)';
    drag.el.classList.toggle('show-like', drag.dx > 30);
    drag.el.classList.toggle('show-nope', drag.dx < -30);
  }

  // ドラッグ終了:しきい値で飛ばす or 戻す
  function onUp() {
    if (!drag) return;
    const { el, dx } = drag;
    el.classList.remove('is-drag');
    el.removeEventListener('pointermove', onMove);
    el.removeEventListener('pointerup', onUp);
    el.removeEventListener('pointercancel', onUp);
    drag = null;

    if (Math.abs(dx) > THRESHOLD) {
      fly(el, dx > 0 ? 1 : -1);
    } else {
      el.style.transform = '';
      el.classList.remove('show-like', 'show-nope');
    }
  }

  // 画面外へ飛ばして除去
  const fly = (el, dir) => {
    el.classList.add('is-gone');
    el.style.transform = 'translate(' + (dir * 460) + 'px,-40px) rotate(' + (dir * 28) + 'deg)';
    el.style.opacity = '0';
    setStatus(dir > 0 ? '♥ 推しに追加!' : '✕ スキップ');
    const remove = () => {
      cards = cards.filter((c) => c !== el);
      el.remove();
      updateStack();
      if (!cards.length && resetBtn) {
        resetBtn.hidden = false;
        setStatus('全員チェック完了!');
      }
    };
    el.addEventListener('transitionend', remove, { once: true });
    setTimeout(remove, 600); // フォールバック
  };

  // ボタン操作でも先頭カードを飛ばす
  if (likeBtn) likeBtn.addEventListener('click', () => { const el = top(); if (el) fly(el, 1); });
  if (nopeBtn) nopeBtn.addEventListener('click', () => { const el = top(); if (el) fly(el, -1); });
  if (resetBtn) resetBtn.addEventListener('click', build);

  build();
}

コード

HTML
<!-- スワイプカード:重なったカードをドラッグし、左右に振り切ると回転しながら飛んで消える -->
<div class="sc-stage">
  <div class="sc-deck" data-deck>
    <!-- カードは JS で動的生成 -->
  </div>

  <div class="sc-controls">
    <button class="sc-btn sc-btn--no" data-nope aria-label="見送る">✕</button>
    <p class="sc-status" data-status aria-live="polite">ドラッグして左右に振り分け</p>
    <button class="sc-btn sc-btn--yes" data-like aria-label="いいね">♥</button>
  </div>

  <button class="sc-reset" data-reset hidden>もう一度</button>
</div>
CSS
:root{
  --bg:#0d1117;
  --text:#e6edf3;
  --muted:#8b949e;
  --yes:#22c55e;
  --no:#ef4444;
  --line:#262d38;
}
*{box-sizing:border-box}
body{
  margin:0;min-height:100vh;position:relative;overflow:hidden;
  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);
}
.sc-stage{width:min(300px,100%);text-align:center}
/* カードを重ねるデッキ */
.sc-deck{
  position:relative;height:230px;margin:0 auto 16px;
  width:200px;
}
.sc-card{
  position:absolute;inset:0;
  border-radius:18px;overflow:hidden;cursor:grab;
  border:1px solid var(--line);
  box-shadow:0 20px 40px -20px rgba(0,0,0,.8);
  color:#fff;user-select:none;touch-action:none;
  display:flex;flex-direction:column;justify-content:flex-end;
  /* JS が --i(奥行き)を更新 */
  transform:translateY(calc(var(--i,0) * 10px)) scale(calc(1 - var(--i,0) * .05));
  transition:transform .35s cubic-bezier(.2,.9,.3,1.2),opacity .35s;
  background:var(--grad,linear-gradient(160deg,#6366f1,#a855f7));
}
.sc-card.is-drag{cursor:grabbing;transition:none}
.sc-card.is-gone{transition:transform .5s ease,opacity .5s ease}
.sc-card__body{
  padding:16px;background:linear-gradient(transparent,rgba(0,0,0,.5));
  text-align:left;
}
.sc-card__emoji{font-size:2.4rem;line-height:1;margin-bottom:6px}
.sc-card__name{margin:0;font-size:1.15rem;font-weight:700}
.sc-card__meta{margin:2px 0 0;font-size:.82rem;opacity:.85}
/* スワイプ方向ラベル */
.sc-card__stamp{
  position:absolute;top:16px;font-size:1.1rem;font-weight:800;letter-spacing:.08em;
  padding:5px 12px;border-radius:9px;border:3px solid currentColor;
  opacity:0;transition:opacity .12s;
}
.sc-card__stamp--like{left:14px;color:var(--yes);transform:rotate(-14deg)}
.sc-card__stamp--nope{right:14px;color:var(--no);transform:rotate(14deg)}
.sc-card.show-like .sc-card__stamp--like{opacity:1}
.sc-card.show-nope .sc-card__stamp--nope{opacity:1}

/* 操作ボタン行 */
.sc-controls{display:flex;align-items:center;justify-content:center;gap:14px}
.sc-btn{
  flex:none;width:48px;height:48px;border-radius:50%;cursor:pointer;
  font-size:1.2rem;font-weight:700;color:#fff;border:1px solid var(--line);
  background:#161b22;transition:transform .15s,filter .2s,border-color .2s;
}
.sc-btn:hover{filter:brightness(1.15)}
.sc-btn:active{transform:scale(.92)}
.sc-btn--no{color:var(--no);border-color:rgba(239,68,68,.5)}
.sc-btn--yes{color:var(--yes);border-color:rgba(34,197,94,.5)}
.sc-status{flex:1;margin:0;color:var(--muted);font-size:.8rem;min-width:0}
/* リセット */
.sc-reset{
  margin-top:14px;font:inherit;font-weight:600;cursor:pointer;color:var(--text);
  background:#1f2937;border:1px solid var(--line);border-radius:10px;padding:9px 18px;
}
.sc-reset[hidden]{display:none}
.sc-reset:hover{border-color:var(--muted)}
@media (prefers-reduced-motion:reduce){
  .sc-card,.sc-card.is-gone{transition:opacity .3s}
}
JavaScript
// スワイプカード:Pointer Events でドラッグ。しきい値を超えたら飛ばし、未満なら戻す
const deck = document.querySelector('[data-deck]');
const statusEl = document.querySelector('[data-status]');
const resetBtn = document.querySelector('[data-reset]');
const likeBtn = document.querySelector('[data-like]');
const nopeBtn = document.querySelector('[data-nope]');

// カードのもとデータ
const DATA = [
  { emoji: '🏔️', name: '山あいの温泉', meta: '長野・1泊2食', grad: 'linear-gradient(160deg,#0ea5e9,#6366f1)' },
  { emoji: '🍜', name: '深夜の屋台ラーメン', meta: '福岡・徒歩5分', grad: 'linear-gradient(160deg,#f59e0b,#ef4444)' },
  { emoji: '🌊', name: '離島のビーチ', meta: '沖縄・3日間', grad: 'linear-gradient(160deg,#06b6d4,#10b981)' },
  { emoji: '🎨', name: '現代美術館めぐり', meta: '金沢・日帰り', grad: 'linear-gradient(160deg,#8b5cf6,#ec4899)' },
  { emoji: '🌃', name: '夜景の見える展望台', meta: '神戸・夜', grad: 'linear-gradient(160deg,#475569,#6366f1)' },
];

if (deck) {
  const THRESHOLD = 90; // 振り切り判定(px)
  let cards = [];       // 残っているカード要素(末尾が最前面)
  let drag = null;      // ドラッグ状態

  // デッキを初期化(生成)
  const build = () => {
    deck.innerHTML = '';
    cards = DATA.map((d) => {
      const el = document.createElement('article');
      el.className = 'sc-card';
      el.style.setProperty('--grad', d.grad);
      el.innerHTML =
        '<span class="sc-card__stamp sc-card__stamp--like">LIKE</span>' +
        '<span class="sc-card__stamp sc-card__stamp--nope">NOPE</span>' +
        '<div class="sc-card__body">' +
        '<div class="sc-card__emoji"></div>' +
        '<h3 class="sc-card__name"></h3>' +
        '<p class="sc-card__meta"></p></div>';
      // 文字列は textContent で安全に挿入
      el.querySelector('.sc-card__emoji').textContent = d.emoji;
      el.querySelector('.sc-card__name').textContent = d.name;
      el.querySelector('.sc-card__meta').textContent = d.meta;
      el.addEventListener('pointerdown', onDown);
      deck.appendChild(el);
      return el;
    });
    updateStack();
    if (resetBtn) resetBtn.hidden = true;
    setStatus('ドラッグして左右に振り分け');
  };

  // 奥行き(--i)を付け直し、最前面だけ操作可能に
  const updateStack = () => {
    const n = cards.length;
    cards.forEach((el, idx) => {
      const depth = n - 1 - idx;             // 末尾=0(最前面)
      el.style.setProperty('--i', Math.min(depth, 3));
      el.style.pointerEvents = idx === n - 1 ? 'auto' : 'none';
      el.style.zIndex = String(idx);
    });
  };

  const top = () => cards[cards.length - 1] || null;
  const setStatus = (t) => { if (statusEl) statusEl.textContent = t; };

  // ドラッグ開始
  function onDown(e) {
    const el = top();
    if (!el || e.currentTarget !== el) return;
    drag = { el, startX: e.clientX, startY: e.clientY, dx: 0 };
    el.classList.add('is-drag');
    el.setPointerCapture(e.pointerId);
    el.addEventListener('pointermove', onMove);
    el.addEventListener('pointerup', onUp);
    el.addEventListener('pointercancel', onUp);
  }

  // ドラッグ中:移動+回転+方向ラベル
  function onMove(e) {
    if (!drag) return;
    drag.dx = e.clientX - drag.startX;
    const dy = (e.clientY - drag.startY) * 0.4;
    const rot = drag.dx * 0.06;
    drag.el.style.transform =
      'translate(' + drag.dx + 'px,' + dy + 'px) rotate(' + rot + 'deg)';
    drag.el.classList.toggle('show-like', drag.dx > 30);
    drag.el.classList.toggle('show-nope', drag.dx < -30);
  }

  // ドラッグ終了:しきい値で飛ばす or 戻す
  function onUp() {
    if (!drag) return;
    const { el, dx } = drag;
    el.classList.remove('is-drag');
    el.removeEventListener('pointermove', onMove);
    el.removeEventListener('pointerup', onUp);
    el.removeEventListener('pointercancel', onUp);
    drag = null;

    if (Math.abs(dx) > THRESHOLD) {
      fly(el, dx > 0 ? 1 : -1);
    } else {
      // 戻す
      el.style.transform = '';
      el.classList.remove('show-like', 'show-nope');
    }
  }

  // 画面外へ回転しながら飛ばして除去
  const fly = (el, dir) => {
    el.classList.add('is-gone');
    el.style.transform =
      'translate(' + (dir * 460) + 'px,-40px) rotate(' + (dir * 28) + 'deg)';
    el.style.opacity = '0';
    setStatus(dir > 0 ? '♥ いいね!' : '✕ 見送り');
    const remove = () => {
      cards = cards.filter((c) => c !== el);
      el.remove();
      updateStack();
      if (!cards.length && resetBtn) {
        resetBtn.hidden = false;
        setStatus('すべて振り分け完了');
      }
    };
    el.addEventListener('transitionend', remove, { once: true });
    // 念のためのフォールバック
    setTimeout(remove, 600);
  };

  // ボタン操作でも先頭カードを飛ばす
  if (likeBtn) likeBtn.addEventListener('click', () => { const el = top(); if (el) fly(el, 1); });
  if (nopeBtn) nopeBtn.addEventListener('click', () => { const el = top(); if (el) fly(el, -1); });
  if (resetBtn) resetBtn.addEventListener('click', build);

  build();
}

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

このままコピーしてAIに貼り付け「追加する場所」だけ書き換えればOK
あなたは熟練のフロントエンドエンジニアです。私のWebサイトに「スワイプカード(Tinder風)」の効果を追加してください。

# 追加してほしい効果
スワイプカード(Tinder風)(UIコンポーネント)
重なったカードをPointer Eventsでドラッグし、左右に振り切ると回転しながら飛んで消え次が前面に。しきい値未満は元に戻ります。マッチングや選別UIに使えます。

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

# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- スワイプカード:重なったカードをドラッグし、左右に振り切ると回転しながら飛んで消える -->
<div class="sc-stage">
  <div class="sc-deck" data-deck>
    <!-- カードは JS で動的生成 -->
  </div>

  <div class="sc-controls">
    <button class="sc-btn sc-btn--no" data-nope aria-label="見送る">✕</button>
    <p class="sc-status" data-status aria-live="polite">ドラッグして左右に振り分け</p>
    <button class="sc-btn sc-btn--yes" data-like aria-label="いいね">♥</button>
  </div>

  <button class="sc-reset" data-reset hidden>もう一度</button>
</div>

【CSS】
:root{
  --bg:#0d1117;
  --text:#e6edf3;
  --muted:#8b949e;
  --yes:#22c55e;
  --no:#ef4444;
  --line:#262d38;
}
*{box-sizing:border-box}
body{
  margin:0;min-height:100vh;position:relative;overflow:hidden;
  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);
}
.sc-stage{width:min(300px,100%);text-align:center}
/* カードを重ねるデッキ */
.sc-deck{
  position:relative;height:230px;margin:0 auto 16px;
  width:200px;
}
.sc-card{
  position:absolute;inset:0;
  border-radius:18px;overflow:hidden;cursor:grab;
  border:1px solid var(--line);
  box-shadow:0 20px 40px -20px rgba(0,0,0,.8);
  color:#fff;user-select:none;touch-action:none;
  display:flex;flex-direction:column;justify-content:flex-end;
  /* JS が --i(奥行き)を更新 */
  transform:translateY(calc(var(--i,0) * 10px)) scale(calc(1 - var(--i,0) * .05));
  transition:transform .35s cubic-bezier(.2,.9,.3,1.2),opacity .35s;
  background:var(--grad,linear-gradient(160deg,#6366f1,#a855f7));
}
.sc-card.is-drag{cursor:grabbing;transition:none}
.sc-card.is-gone{transition:transform .5s ease,opacity .5s ease}
.sc-card__body{
  padding:16px;background:linear-gradient(transparent,rgba(0,0,0,.5));
  text-align:left;
}
.sc-card__emoji{font-size:2.4rem;line-height:1;margin-bottom:6px}
.sc-card__name{margin:0;font-size:1.15rem;font-weight:700}
.sc-card__meta{margin:2px 0 0;font-size:.82rem;opacity:.85}
/* スワイプ方向ラベル */
.sc-card__stamp{
  position:absolute;top:16px;font-size:1.1rem;font-weight:800;letter-spacing:.08em;
  padding:5px 12px;border-radius:9px;border:3px solid currentColor;
  opacity:0;transition:opacity .12s;
}
.sc-card__stamp--like{left:14px;color:var(--yes);transform:rotate(-14deg)}
.sc-card__stamp--nope{right:14px;color:var(--no);transform:rotate(14deg)}
.sc-card.show-like .sc-card__stamp--like{opacity:1}
.sc-card.show-nope .sc-card__stamp--nope{opacity:1}

/* 操作ボタン行 */
.sc-controls{display:flex;align-items:center;justify-content:center;gap:14px}
.sc-btn{
  flex:none;width:48px;height:48px;border-radius:50%;cursor:pointer;
  font-size:1.2rem;font-weight:700;color:#fff;border:1px solid var(--line);
  background:#161b22;transition:transform .15s,filter .2s,border-color .2s;
}
.sc-btn:hover{filter:brightness(1.15)}
.sc-btn:active{transform:scale(.92)}
.sc-btn--no{color:var(--no);border-color:rgba(239,68,68,.5)}
.sc-btn--yes{color:var(--yes);border-color:rgba(34,197,94,.5)}
.sc-status{flex:1;margin:0;color:var(--muted);font-size:.8rem;min-width:0}
/* リセット */
.sc-reset{
  margin-top:14px;font:inherit;font-weight:600;cursor:pointer;color:var(--text);
  background:#1f2937;border:1px solid var(--line);border-radius:10px;padding:9px 18px;
}
.sc-reset[hidden]{display:none}
.sc-reset:hover{border-color:var(--muted)}
@media (prefers-reduced-motion:reduce){
  .sc-card,.sc-card.is-gone{transition:opacity .3s}
}

【JavaScript】
// スワイプカード:Pointer Events でドラッグ。しきい値を超えたら飛ばし、未満なら戻す
const deck = document.querySelector('[data-deck]');
const statusEl = document.querySelector('[data-status]');
const resetBtn = document.querySelector('[data-reset]');
const likeBtn = document.querySelector('[data-like]');
const nopeBtn = document.querySelector('[data-nope]');

// カードのもとデータ
const DATA = [
  { emoji: '🏔️', name: '山あいの温泉', meta: '長野・1泊2食', grad: 'linear-gradient(160deg,#0ea5e9,#6366f1)' },
  { emoji: '🍜', name: '深夜の屋台ラーメン', meta: '福岡・徒歩5分', grad: 'linear-gradient(160deg,#f59e0b,#ef4444)' },
  { emoji: '🌊', name: '離島のビーチ', meta: '沖縄・3日間', grad: 'linear-gradient(160deg,#06b6d4,#10b981)' },
  { emoji: '🎨', name: '現代美術館めぐり', meta: '金沢・日帰り', grad: 'linear-gradient(160deg,#8b5cf6,#ec4899)' },
  { emoji: '🌃', name: '夜景の見える展望台', meta: '神戸・夜', grad: 'linear-gradient(160deg,#475569,#6366f1)' },
];

if (deck) {
  const THRESHOLD = 90; // 振り切り判定(px)
  let cards = [];       // 残っているカード要素(末尾が最前面)
  let drag = null;      // ドラッグ状態

  // デッキを初期化(生成)
  const build = () => {
    deck.innerHTML = '';
    cards = DATA.map((d) => {
      const el = document.createElement('article');
      el.className = 'sc-card';
      el.style.setProperty('--grad', d.grad);
      el.innerHTML =
        '<span class="sc-card__stamp sc-card__stamp--like">LIKE</span>' +
        '<span class="sc-card__stamp sc-card__stamp--nope">NOPE</span>' +
        '<div class="sc-card__body">' +
        '<div class="sc-card__emoji"></div>' +
        '<h3 class="sc-card__name"></h3>' +
        '<p class="sc-card__meta"></p></div>';
      // 文字列は textContent で安全に挿入
      el.querySelector('.sc-card__emoji').textContent = d.emoji;
      el.querySelector('.sc-card__name').textContent = d.name;
      el.querySelector('.sc-card__meta').textContent = d.meta;
      el.addEventListener('pointerdown', onDown);
      deck.appendChild(el);
      return el;
    });
    updateStack();
    if (resetBtn) resetBtn.hidden = true;
    setStatus('ドラッグして左右に振り分け');
  };

  // 奥行き(--i)を付け直し、最前面だけ操作可能に
  const updateStack = () => {
    const n = cards.length;
    cards.forEach((el, idx) => {
      const depth = n - 1 - idx;             // 末尾=0(最前面)
      el.style.setProperty('--i', Math.min(depth, 3));
      el.style.pointerEvents = idx === n - 1 ? 'auto' : 'none';
      el.style.zIndex = String(idx);
    });
  };

  const top = () => cards[cards.length - 1] || null;
  const setStatus = (t) => { if (statusEl) statusEl.textContent = t; };

  // ドラッグ開始
  function onDown(e) {
    const el = top();
    if (!el || e.currentTarget !== el) return;
    drag = { el, startX: e.clientX, startY: e.clientY, dx: 0 };
    el.classList.add('is-drag');
    el.setPointerCapture(e.pointerId);
    el.addEventListener('pointermove', onMove);
    el.addEventListener('pointerup', onUp);
    el.addEventListener('pointercancel', onUp);
  }

  // ドラッグ中:移動+回転+方向ラベル
  function onMove(e) {
    if (!drag) return;
    drag.dx = e.clientX - drag.startX;
    const dy = (e.clientY - drag.startY) * 0.4;
    const rot = drag.dx * 0.06;
    drag.el.style.transform =
      'translate(' + drag.dx + 'px,' + dy + 'px) rotate(' + rot + 'deg)';
    drag.el.classList.toggle('show-like', drag.dx > 30);
    drag.el.classList.toggle('show-nope', drag.dx < -30);
  }

  // ドラッグ終了:しきい値で飛ばす or 戻す
  function onUp() {
    if (!drag) return;
    const { el, dx } = drag;
    el.classList.remove('is-drag');
    el.removeEventListener('pointermove', onMove);
    el.removeEventListener('pointerup', onUp);
    el.removeEventListener('pointercancel', onUp);
    drag = null;

    if (Math.abs(dx) > THRESHOLD) {
      fly(el, dx > 0 ? 1 : -1);
    } else {
      // 戻す
      el.style.transform = '';
      el.classList.remove('show-like', 'show-nope');
    }
  }

  // 画面外へ回転しながら飛ばして除去
  const fly = (el, dir) => {
    el.classList.add('is-gone');
    el.style.transform =
      'translate(' + (dir * 460) + 'px,-40px) rotate(' + (dir * 28) + 'deg)';
    el.style.opacity = '0';
    setStatus(dir > 0 ? '♥ いいね!' : '✕ 見送り');
    const remove = () => {
      cards = cards.filter((c) => c !== el);
      el.remove();
      updateStack();
      if (!cards.length && resetBtn) {
        resetBtn.hidden = false;
        setStatus('すべて振り分け完了');
      }
    };
    el.addEventListener('transitionend', remove, { once: true });
    // 念のためのフォールバック
    setTimeout(remove, 600);
  };

  // ボタン操作でも先頭カードを飛ばす
  if (likeBtn) likeBtn.addEventListener('click', () => { const el = top(); if (el) fly(el, 1); });
  if (nopeBtn) nopeBtn.addEventListener('click', () => { const el = top(); if (el) fly(el, -1); });
  if (resetBtn) resetBtn.addEventListener('click', build);

  build();
}

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

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