スワイプカード(Tinder風)
重なったカードをPointer Eventsでドラッグし、左右に振り切ると回転しながら飛んで消え次が前面に。しきい値未満は元に戻ります。マッチングや選別UIに使えます。
ライブデモ
使用例(お題: アイドルグループ 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で提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。