本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
カード

重なったカードを横にはじいて次へ送る

デモ

操作:ドラッグ・クリック。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。

仕様

用途
カード
動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

カードを同じ位置に重ね、山の中の深さ(一番上が 0)ごとに、下へのずれ・縮み・傾き・横のずれを決めて山にします。一番上のカードは引いた量に比例して傾け、回す原点をつかむあたり(下寄りの右)に置くと、指に付いてくるように見えます。引いた向きの判(「保存」「見送り」)と下のボタンの塗りは、引いた量だけ出します。

傾きは 1px 引くごとに 0.045°、後ろの段は 1 段ごとに 20px 下げて 5% 縮めます。放したら、放した瞬間の速さのまま毎秒毎秒 3000px で加速させて飛ばし、0.42 秒で薄れて消します。下のカードは引いている間に 3 割だけ先に上がり、放したら残りを 0.6 秒の power3.out で上がります。カードの幅の 3 割を越えて引いたか、その向きへはじいたら送り、足りなければ back.out で弾ませて戻します。

後ろの段は地の色の幕で沈め、本文には面の色の霞をかけて、下端からのぞく文字を隠します。文字そのものの opacity を下げると、重なった文字が透けて読みにくくなります。幕と霞の濃さは、深さを表す CSS 変数 1 つから calc() で組むので、JS は深さを動かすだけです。飛んでいくカードでページが横にはみ出さないよう、節に overflow-x: clip を付けます。

引けない人のために、下のボタンと ← → キーでも同じ送り方を呼びます。読み上げの対象は一番上のカードだけにし、送ったら「〜を保存しました」と role="status" で知らせます。動きを減らす設定では、飛ばさずにその場で入れ替えます。このデモでは、カードを引くか、下のボタンを押すと送れます。

飛んだカードは山のいちばん後ろへ回すので、全部送ると最初の並びに戻ります。捨てる作りにするなら、送ったカードを山から外します。傾きの割合を大きくすると軽く、加速を小さくするとゆっくり飛びます。候補の選択(保存・見送り)、商品や宿のおすすめ、オンボーディングのカードに向いています。

コード

HTML
<section class="swipe" aria-labelledby="swipe-title">
  <div class="swipe__copy">
    <p class="swipe__eyebrow">Weekend escapes</p>
    <h2 class="swipe__title" id="swipe-title">次の週末は、<br>どの宿に<br>泊まろう。</h2>
    <p class="swipe__lead">ここに説明文が入ります。</p>
    <!-- 引く向きの手がかり。キーボードでは、下のボタンか ← → キーで送れる -->
    <ul class="swipe__hints" aria-hidden="true">
      <li>← 見送る</li>
      <li>保存 →</li>
    </ul>
  </div>

  <div class="swipe__area">
    <!-- 先頭が一番上のカード。後ろのカードは JS が読み上げから外す -->
    <ul class="swipe__deck">
      <li class="swipe__card">
        <img class="swipe__art" src="/img/misaki-koya.jpg" alt="夕暮れの海を見下ろす崖の上の小屋" width="336" height="266">
        <span class="swipe__pill">海が見える</span>
        <div class="swipe__body">
          <div class="swipe__row"><h3 class="swipe__name">岬の小屋</h3><span class="swipe__rating">★ 4.9</span></div>
          <p class="swipe__place">房総の海辺・車で2時間</p>
          <p class="swipe__price"><span><b>¥34,000</b>/泊</span><span>金曜〜日曜</span></p>
        </div>
        <span class="swipe__stamp swipe__stamp--save" aria-hidden="true">保存</span>
        <span class="swipe__stamp swipe__stamp--skip" aria-hidden="true">見送り</span>
      </li>
      <li class="swipe__card">
        <img class="swipe__art" src="/img/kohan-sanso.jpg" alt="雪山のふもとの湖のほとりに建つ家" width="336" height="266">
        <span class="swipe__pill">湖のほとり</span>
        <div class="swipe__body">
          <div class="swipe__row"><h3 class="swipe__name">湖畔の山荘</h3><span class="swipe__rating">★ 4.8</span></div>
          <p class="swipe__place">山あいの湖・電車で3時間</p>
          <p class="swipe__price"><span><b>¥44,000</b>/泊</span><span>金曜〜日曜</span></p>
        </div>
        <span class="swipe__stamp swipe__stamp--save" aria-hidden="true">保存</span>
        <span class="swipe__stamp swipe__stamp--skip" aria-hidden="true">見送り</span>
      </li>
      <li class="swipe__card">
        <img class="swipe__art" src="/img/sakyu-dome.jpg" alt="砂丘の間に建つ白いドームの宿" width="336" height="266">
        <span class="swipe__pill">星空が見える</span>
        <div class="swipe__body">
          <div class="swipe__row"><h3 class="swipe__name">砂丘のドーム</h3><span class="swipe__rating">★ 4.7</span></div>
          <p class="swipe__place">鳥取砂丘・飛行機で1時間</p>
          <p class="swipe__price"><span><b>¥26,000</b>/泊</span><span>金曜〜日曜</span></p>
        </div>
        <span class="swipe__stamp swipe__stamp--save" aria-hidden="true">保存</span>
        <span class="swipe__stamp swipe__stamp--skip" aria-hidden="true">見送り</span>
      </li>
      <li class="swipe__card">
        <img class="swipe__art" src="/img/mori-treehouse.jpg" alt="月夜の松林に建つツリーハウス" width="336" height="266">
        <span class="swipe__pill">焚き火ができる</span>
        <div class="swipe__body">
          <div class="swipe__row"><h3 class="swipe__name">森のツリーハウス</h3><span class="swipe__rating">★ 5.0</span></div>
          <p class="swipe__place">松林の谷・車で90分</p>
          <p class="swipe__price"><span><b>¥22,000</b>/泊</span><span>金曜〜日曜</span></p>
        </div>
        <span class="swipe__stamp swipe__stamp--save" aria-hidden="true">保存</span>
        <span class="swipe__stamp swipe__stamp--skip" aria-hidden="true">見送り</span>
      </li>
    </ul>

    <!-- 引かなくても送れるように、同じ送り方をボタンにも付ける -->
    <div class="swipe__actions">
      <button class="swipe__button swipe__button--skip" type="button" data-dir="-1" aria-label="見送る">
        <svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true"><path d="M7 7l10 10M17 7 7 17"/></svg>
      </button>
      <button class="swipe__button swipe__button--save" type="button" data-dir="1" aria-label="保存する">
        <svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true"><path d="M12 20.3s-7.3-4.5-7.3-10.1A4.1 4.1 0 0 1 12 7.6a4.1 4.1 0 0 1 7.3 2.6c0 5.6-7.3 10.1-7.3 10.1z"/></svg>
      </button>
    </div>
    <p class="swipe__status" role="status" data-save="を保存しました" data-skip="を見送りました"></p>
  </div>
</section>

CSS
.swipe {
  --bg: #111111;
  --surface: #262626;
  --ink: #eeece6;
  --mute-small: #a8a6a0;
  --line: rgb(238 236 230 / 0.14);
  --save: #4f8a64;    /* 保存(成功の色) */
  --skip: #c4553d;    /* 見送り(エラーの色) */
  --on-art: #ffffff;  /* 写真の上の札と判の地 */
  display: grid;
  grid-template-columns: minmax(0, 512px) minmax(0, 336px);
  align-items: center;
  gap: 64px 96px;
  padding: 72px max(24px, calc((100% - 1088px) / 2));
  background: var(--bg);
  color: var(--ink);
  overflow-x: clip; /* 飛んでいくカードで、ページが横にはみ出さないように */
}

.swipe__eyebrow { display: flex; align-items: center; gap: 12px; margin: 0; font-size: 13px; font-weight: 700; line-height: 1; letter-spacing: 0.14em; text-transform: uppercase; color: var(--mute-small); }
.swipe__eyebrow::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: currentColor; }
.swipe__title { margin: 24px 0 0; font-size: clamp(40px, 7vw, 64px); font-weight: 800; line-height: 1.05; letter-spacing: -0.02em; }
.swipe__lead { margin: 24px 0 0; font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; word-break: keep-all; }
.swipe__hints { display: flex; gap: 8px; margin: 48px 0 0; padding: 0; list-style: none; }
.swipe__hints li { padding: 0 16px; border-radius: 999px; box-shadow: inset 0 0 0 1px var(--line); font-size: 14px; font-weight: 600; line-height: 40px; letter-spacing: 0.04em; white-space: nowrap; }

.swipe__area { display: grid; justify-items: center; gap: 64px; }
.swipe__deck { position: relative; width: min(336px, 100%); aspect-ratio: 336 / 416; margin: 0; padding: 0; list-style: none; user-select: none; }

/* カードは同じ位置に重ねる。回す原点は、つかむあたり(下寄りの右)に置く */
.swipe__card { position: absolute; inset: 0; overflow: hidden; border-radius: 4px; background: var(--surface); box-shadow: inset 0 0 0 1px var(--line); transform-origin: 72% 78%; touch-action: none; }
.swipe__card:first-child { z-index: 1; } /* JS の前は、先頭のカードを上に見せる */
.swipe__card:not([aria-hidden="true"]) { cursor: grab; }
.swipe__card:active { cursor: grabbing; }
/* 後ろの段は、本文に面の色の霞をかけ(::before)、地の色の幕で沈める(::after)。濃さは JS が書く --depth で決まる */
.swipe__card::before { content: ""; position: absolute; inset: 64% 1px 1px; z-index: 1; background: var(--surface); opacity: calc(min(var(--depth, 0), 1) * 0.85); }
.swipe__card::after { content: ""; position: absolute; inset: 0; z-index: 3; background: var(--bg); opacity: calc(min(var(--depth, 0), 2) * 0.22); }

.swipe__art { position: absolute; left: 0; top: 0; width: 100%; height: 64%; object-fit: cover; pointer-events: none; }
.swipe__pill { position: absolute; left: 24px; top: calc(64% - 52px); padding: 0 12px; border-radius: 999px; background: var(--on-art); color: var(--bg); font-size: 13px; font-weight: 700; line-height: 28px; letter-spacing: 0.06em; white-space: nowrap; }
.swipe__body { position: absolute; left: 24px; right: 24px; top: calc(64% + 16px); bottom: 24px; display: flex; flex-direction: column; }
.swipe__row { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; }
.swipe__name { margin: 0; font-size: 24px; font-weight: 700; line-height: 1.3; letter-spacing: -0.01em; white-space: nowrap; }
.swipe__rating { flex: none; font-size: 14px; font-weight: 600; letter-spacing: 0.04em; font-variant-numeric: tabular-nums; }
.swipe__place,
.swipe__price { margin: 4px 0 0; font-size: 14px; line-height: 1.4; letter-spacing: 0.04em; color: var(--mute-small); white-space: nowrap; }
.swipe__price { display: flex; justify-content: space-between; align-items: baseline; margin-top: auto; }
.swipe__price b { font-size: 20px; letter-spacing: 0; color: var(--ink); font-variant-numeric: tabular-nums; }

/* 判は、引いた向きのものを引いた量だけ出す(JS が書く --pull。カードの幅の 57% で 1) */
.swipe__stamp { position: absolute; top: 24px; z-index: 2; padding: 4px 12px 4px 14px; border: 4px solid; border-radius: 4px; background: var(--on-art); font-size: 28px; font-weight: 700; line-height: 1.2; letter-spacing: 0.06em; white-space: nowrap; }
.swipe__stamp--save { left: 24px; color: var(--save); rotate: -12deg; opacity: clamp(0, calc(var(--pull, 0) / 0.55), 1); }
.swipe__stamp--skip { right: 24px; color: var(--skip); rotate: 12deg; opacity: clamp(0, calc(var(--pull, 0) / -0.55), 1); }

.swipe__actions { display: flex; gap: 32px; }
.swipe__button { position: relative; display: grid; place-items: center; width: 56px; height: 56px; padding: 0; border: 0; border-radius: 50%; background: none; box-shadow: inset 0 0 0 1px var(--ink); color: var(--ink); cursor: pointer; }
/* 塗りは線ごと覆う。引いている向きのボタンを、引いた量だけ色づける(JS が書く --fill。正は保存、負は見送り) */
.swipe__button::before { content: ""; position: absolute; inset: 0; border-radius: 50%; background: var(--save); opacity: clamp(0, var(--fill, 0), 1); }
.swipe__button--skip::before { background: var(--skip); opacity: clamp(0, calc(var(--fill, 0) * -1), 1); }
.swipe__button svg { position: relative; }
.swipe__button path { fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.swipe__button:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }

.swipe__status { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

/* 狭い画面では文言の下に山を置く(山とボタンは、.swipe__area の中で中央にそろう) */
@media (max-width: 960px) {
  .swipe { grid-template-columns: minmax(0, 1fr); }
}

JavaScript
import { gsap } from "gsap";

const TILT = 0.045; // 横へ 1px 引くごとの傾き(度)
const STEP = { y: 20, scale: 0.05 }; // 後ろの段を 1 段ごとに下げる量(px)と縮める量
const ROT = [0, 3.5, -4, 2]; // 深さごとの傾き(度)。いちばん後ろの段は見えない
const SHIFT = [0, 0.03, -0.03, 0]; // 深さごとの横のずれ(カードの幅に対する比)
const PRE_LIFT = 0.3; // 引いている間に、後ろのカードが先に上がる割合
const ACCEL = 3000; // 飛ぶときの加速(px/s²)
const FLY = 0.42; // 放してから消えきるまで(秒)
const LAST = ROT.length - 1;
const k = window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 1; // 動きを減らす設定では、その場で置き換える
const clamp = gsap.utils.clamp(0, 1);

// 山の中の深さ(0 = 一番上。小数は段の間)に置く値。--depth は、CSS が後ろの段を沈めるのに使う
function pose(card, depth) {
  const d = gsap.utils.clamp(0, LAST, depth);
  const i = Math.min(Math.floor(d), LAST - 1);
  const mix = (list) => gsap.utils.interpolate(list[i], list[i + 1], d - i);
  return { x: mix(SHIFT) * card.offsetWidth, y: d * STEP.y, rotation: mix(ROT), scale: 1 - d * STEP.scale, "--depth": d, autoAlpha: clamp(LAST - d) };
}

document.querySelectorAll(".swipe").forEach((section) => {
  const deck = [...section.querySelectorAll(".swipe__card")]; // 先頭が一番上
  const status = section.querySelector(".swipe__status");
  const flying = new Set();
  let drag = null; // 引いている間だけ { id, from, samples }
  const reach = () => deck[0].offsetWidth * 0.57; // ここまで引くと、判とボタンの塗りが出きる
  // 並びどおりに重ねる。lift は後ろのカードが先に上がった量(0〜1 段)。引いているカードと飛んでいるカードは動かさない
  const layout = (lift, vars) => deck.forEach((card, d) => {
    card.setAttribute("aria-hidden", String(d > 0));
    if (flying.has(card) || (d === 0 && drag)) return;
    card.style.zIndex = deck.length - d;
    gsap.to(card, { ...pose(card, d && d - lift), ...vars, overwrite: true });
  });

  // 一番上のカードを dir の向き(1 = 保存、-1 = 見送り)へ送る。放した瞬間の速さのまま加速して飛ばし、山のいちばん後ろへ回す
  const send = (dir, vx = 0) => {
    const card = deck.shift();
    deck.push(card);
    const [x0, y0, v, clock] = [gsap.getProperty(card, "x"), gsap.getProperty(card, "y"), dir * Math.max(Math.abs(vx), 640), { t: 0 }];
    flying.add(card);
    card.style.zIndex = deck.length + 1;
    gsap.to(clock, { t: FLY, duration: FLY * k, ease: "none", onUpdate() {
      const x = x0 + v * clock.t + dir * 0.5 * ACCEL * clock.t ** 2;
      // overwrite で、上がってくる途中だったトゥイーンを止める(止めないと、飛び終えたあとに山の上へ戻ってくる)
      gsap.set(card, { x, y: y0 - 46 * clock.t, rotation: x * TILT, "--pull": x / reach(), opacity: 1 - clamp((clock.t - 0.12) / 0.3), overwrite: true });
    }, onComplete() {
      flying.delete(card);
      card.style.zIndex = deck.length - deck.indexOf(card);
      gsap.set(card, { ...pose(card, deck.indexOf(card)), "--pull": 0 });
    } });
    layout(0, { duration: 0.6 * k, ease: "power3.out" });
    // 送った向きのボタンを、放した濃さ(ボタンで送ったら 1)から 0.35 秒で戻す
    gsap.fromTo(section, { "--fill": dir * (vx ? clamp((dir * x0) / reach()) : 1) }, { "--fill": 0, duration: 0.35 * k, ease: "none", overwrite: true });
    status.textContent = card.querySelector(".swipe__name").textContent + status.dataset[dir > 0 ? "save" : "skip"];
  };

  section.querySelector(".swipe__deck").addEventListener("pointerdown", (event) => {
    if (drag || !deck[0].contains(event.target)) return;
    deck[0].setPointerCapture(event.pointerId); // カードの外へ出しても追える
    gsap.killTweensOf([deck[0], section], "x,y,rotation,--pull,--fill");
    drag = { id: event.pointerId, from: { x: event.clientX - gsap.getProperty(deck[0], "x"), y: event.clientY - gsap.getProperty(deck[0], "y") }, samples: [] };
  });
  section.addEventListener("pointermove", (event) => {
    if (event.pointerId !== drag?.id) return;
    const x = event.clientX - drag.from.x;
    gsap.set(deck[0], { x, y: event.clientY - drag.from.y, rotation: x * TILT, "--pull": x / reach() });
    section.style.setProperty("--fill", gsap.utils.clamp(-1, 1, x / reach()));
    layout(PRE_LIFT * clamp(Math.abs(x) / reach()), { duration: 0 });
    drag.samples = [...drag.samples, { x: event.clientX, t: event.timeStamp }].slice(-5);
  });
  const release = (event) => {
    if (event.pointerId !== drag?.id) return;
    // 直前の数回の動きから、放した瞬間の横の速さ(px/s)を出す。止めてから放したら 0
    const [a, b] = [drag.samples[0], drag.samples.at(-1)];
    const vx = a && b.t > a.t && event.timeStamp - b.t < 100 ? ((b.x - a.x) / (b.t - a.t)) * 1000 : 0;
    const x = gsap.getProperty(deck[0], "x");
    const dir = Math.sign(x);
    drag = null;
    // カードの幅の 3 割を越えて引いたか、その向きへはじいたら送る。足りなければ弾んで戻す
    if (dir && (Math.abs(x) > deck[0].offsetWidth * 0.3 || (Math.abs(x) > 20 && vx * dir > 600))) return send(dir, vx);
    gsap.to(deck[0], { x: 0, y: 0, rotation: 0, "--pull": 0, duration: 0.5 * k, ease: "back.out(1.7)", overwrite: true });
    gsap.to(section, { "--fill": 0, duration: 0.3 * k, overwrite: true });
    layout(0, { duration: 0.5 * k, ease: "power3.out" });
  };
  ["pointerup", "pointercancel"].forEach((type) => section.addEventListener(type, release));

  // 引けない人のために、ボタンと ← → キーでも同じ送り方を呼ぶ
  section.querySelectorAll(".swipe__button").forEach((button) => button.addEventListener("click", () => !drag && send(Number(button.dataset.dir))));
  section.addEventListener("keydown", (event) => {
    const dir = { ArrowLeft: -1, ArrowRight: 1 }[event.key];
    if (dir && !drag) {
      event.preventDefault();
      send(dir);
    }
  });
  layout(0, { duration: 0 });
  window.addEventListener("resize", () => layout(0, { duration: 0 }));
});

インタラクションデザインについて、
お気軽にご相談ください。

ここに並べているのは、Web サイトの中で動く UI のサンプルです。新しく作るサイトに組み込むことも、いま公開中のサイトに、ボタンやヒーロー、スクロールの演出だけを足すこともできます。設計から実装までの進め方はWeb サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。