本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
ボタン

ボタンを押すと商品が弧を描いてカートに飛び込み、数字が増える

デモ

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

仕様

用途
ボタン
動作
DOM
種類
レシピ
使うもの
MotionPathPlugin
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

押した商品の画像を複製して同じ位置・大きさに重ね、弧を描いてナビのカートへ飛ばします。複製は縮みながらカートに吸い込まれ、着いた瞬間にカートが弾んで輪が広がり、バッジの数字が下から繰り上がります。ボタンは「追加しました」とチェックに変わり、少しして元に戻ります。押した結果がどこへ行ったかを、目で追わせる動きです。

弧は MotionPathPlugin の 3 次ベジェで、0.9 秒の power1.inOut で飛ばします。制御点は「始点 +(終点 − 始点)× 比率」で決めるので、画像とカートの位置が変わっても同じ形の弧になります。はじめはほぼ真上へ持ち上げ、途中から横へ曲げてカートに横から入れるので、カートが画面の上端にあっても上へはみ出しません。大きさは同じ 0.9 秒で 0.14 倍まで等速で縮め、最後の 0.12 秒で消します。

縮めるのを早めると、飛んでいるものが小さすぎて目で追えず、遅らせると大きいままヘッダーに近づいて上へはみ出します。数字は [今の数, 次の数] の 2 段の列を窓で切り抜き、yPercent を −50 まで動かして繰り上げます。列を inline の span のままにすると transform が効かず、数字が上がりません。着いたときの輪は、バッジの outline の離れと色を動かして広げます。transform で拡大すると、画面の端にあるカートでページが横にはみ出します。

飛ぶ動きは目で見る人向けです。読み上げには、押した時点で「〇〇をカートに入れました。カートの商品は3点です。」と role="status" の領域に書いて届け、カートのリンクの aria-label も「カート(3点)」に書き換えます。バッジの数字とボタンの「追加しました」は見た目だけにして、読み上げから外します。動きを減らす設定では、複製を飛ばさずに、数とボタンだけを切り替えます。

弧の形は制御点の比率で、飛ぶ速さは時間で変えます。着いたときの弾みを強くすると、入った手応えがはっきりします。EC の商品一覧・商品詳細・おすすめ枠の「カートに入れる」に向いています。

コード

HTML
<!-- ヘッダーのカート。数は aria-label と、下の status の読み上げで伝える(バッジの数字は見た目だけ) -->
<header class="shop-head">
  <a class="cart" href="/cart/" aria-label="カート(2点)" data-count="2">
    <svg class="cart__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
      <path d="M6 8h12l-1 12H7L6 8z" />
      <path d="M9 8a3 3 0 0 1 6 0" />
    </svg>
    <!-- 数字は 2 段の列 [今の数, 次の数]。窓で切り抜き、1 段上げて繰り上げる -->
    <span class="cart__badge" aria-hidden="true">
      <span class="cart__digits"><span>2</span><span>3</span></span>
    </span>
  </a>
</header>
<p class="cart-status" role="status"></p>

<!-- 商品カード。data-name は読み上げの文に使う -->
<article class="product" data-name="粉引のマグ">
  <img class="product__image" src="/img/stoneware-mug.jpg" alt="セージ色の陶器のマグカップ" width="320" height="264">
  <h3 class="product__name">粉引のマグ</h3>
  <p class="product__price">¥3,960</p>
  <button class="product__add" type="button">
    <!-- 文言も 2 段の列。2 段目は見た目だけで、名前は「カートに入れる」のまま -->
    <span class="product__labels">
      <span class="product__label">
        <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M6 8h12l-1 12H7L6 8zM9 8a3 3 0 0 1 6 0M12 11v6M9 14h6" /></svg>
        カートに入れる
      </span>
      <span class="product__label" aria-hidden="true">
        <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M5 12.5l4.5 4.5L19 7.5" /></svg>
        追加しました
      </span>
    </span>
  </button>
</article>

CSS
.shop-head,
.product {
  --ink: #262626;       /* 文字・線・主ボタン */
  --ground: #f2f1ed;    /* ページの地(バッジの縁を地の色にして、切り抜いたように見せる) */
  --pressed: #000000;   /* 押した瞬間のボタン */
  --success: #4f8a64;   /* 「追加しました」の地 */
  --ring: rgba(38, 38, 38, 0.9);   /* カートに着いたときに広がる輪 */
  --ring-out: rgba(38, 38, 38, 0); /* 広がりきった輪(同じ色の透明) */
  color: var(--ink);
}

.shop-head { display: flex; justify-content: flex-end; padding: 16px 0 24px; }

/* アイコンだけのリンクなので、線の丸で押せる範囲を見せる */
.cart {
  position: relative;
  display: grid;
  place-items: center;
  width: 48px;
  height: 48px;
  border-radius: 50%;
  box-shadow: inset 0 0 0 1px var(--ink);
  color: inherit;
}

.cart__icon { width: 24px; height: 24px; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }

/* 着いたときに広がる輪は outline で描く(transform で広げると、画面の端でページが横にはみ出す) */
.cart__badge {
  position: absolute;
  top: -6px;
  right: -6px;
  width: 24px;
  height: 24px;
  box-sizing: border-box;
  overflow: hidden;
  border: 2px solid var(--ground);
  border-radius: 50%;
  outline: 2px solid transparent;
  background: var(--ink);
  color: #ffffff;
  font-size: 12px;
  font-weight: 700;
  line-height: 20px;
  text-align: center;
  font-variant-numeric: tabular-nums;
}

/* 列も block にする(inline の span には transform が効かず、列が上がらない) */
.cart__digits { display: block; }
.cart__digits > span { display: block; height: 20px; }

/* 読み上げにだけ届ける文 */
.cart-status { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

.product { max-width: 352px; padding: 16px; border-radius: 4px; background: #ffffff; }
.product__image { display: block; width: 100%; height: auto; background: #cbd8c8; }
.product__name { margin: 16px 0 0; font-size: 20px; line-height: 1.3; }
.product__price { margin: 4px 0 24px; font-size: 15px; font-variant-numeric: tabular-nums; }

.product__add {
  width: 100%;
  height: 48px;
  padding: 0;
  overflow: hidden;
  border: 0;
  border-radius: 999px;
  background-color: var(--ink);
  color: #ffffff;
  font: inherit;
  font-size: 15px;
  font-weight: 600;
  letter-spacing: 0.04em;
  cursor: pointer;
}

.product__labels { display: block; }
.product__label { display: flex; align-items: center; justify-content: center; gap: 8px; height: 48px; }
.product__label svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; }

.cart:focus-visible,
.product__add:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }

/* 飛ぶ複製。画面に固定して、画像と同じ位置から飛ばす。白い縁で、下に残る元の画像と見分ける */
.product__flier {
  position: fixed;
  z-index: 1000;
  margin: 0;
  pointer-events: none;
  box-shadow: 0 0 0 4px #ffffff, 0 24px 48px rgba(17, 17, 17, 0.24);
}

JavaScript
import { gsap } from "gsap";
import { MotionPathPlugin } from "gsap/MotionPathPlugin";

gsap.registerPlugin(MotionPathPlugin);

const FLIGHT = 0.9; // 飛んでいる時間(秒)
const END_SCALE = 0.14; // カートに入るときの大きさ
// 弧の制御点は「始点 + (終点 − 始点) × 比率」。画像とカートの位置が変わっても、同じ形の弧になる
const ARC = [[0.04, 0.7], [0.62, 0.91]];
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const time = (seconds) => (reduceMotion ? 0 : seconds); // 動きを減らす設定では、飛ばさずに一瞬で切り替える

const cart = document.querySelector(".cart");
const icon = cart.querySelector(".cart__icon");
const badge = cart.querySelector(".cart__badge");
const digits = cart.querySelector(".cart__digits");
const [now, next] = digits.children;
const status = document.querySelector(".cart-status");
let count = Number(cart.dataset.count); // カートの数(押した時点で増やし、読み上げに届ける)
let shown = count; // バッジに出ている数(複製が着いた時点で増やす)
now.textContent = String(shown);

// 輪の色と、それを透明にした色(CSS が正)。outline の色を補間して消す
const [ring, ringOut] = ["--ring", "--ring-out"].map((name) => getComputedStyle(cart).getPropertyValue(name).trim());

// 着いたら: 数字の列を [今の数, 次の数] に書き直して 1 段上げ、カートを弾ませて輪を広げる
function bump() {
  now.textContent = String(shown);
  next.textContent = String(shown + 1);
  shown += 1;
  gsap
    .timeline()
    .fromTo(digits, { yPercent: 0 }, { yPercent: -50, duration: time(0.45), ease: "power3.out" }, 0)
    .fromTo(icon, { y: 0 }, { y: -9, duration: time(0.12), ease: "power2.out" }, 0)
    .to(icon, { y: 0, duration: time(0.6), ease: "bounce.out" }, time(0.12))
    .fromTo(badge, { outlineOffset: 0, outlineColor: ring }, { outlineOffset: 16, outlineColor: ringOut, duration: time(0.6), ease: "power2.out" }, 0);
}

// 画像と同じ位置・大きさに複製を重ね、弧を描いてカートへ飛ばす(fixed なので、画面の座標で飛ぶ)
function fly(image, onArrive) {
  const from = image.getBoundingClientRect();
  const to = icon.getBoundingClientRect();
  const dx = to.left + to.width / 2 - (from.left + from.width / 2);
  const dy = to.top + to.height / 2 - (from.top + from.height / 2);
  const clone = image.cloneNode(true);
  clone.alt = ""; // 複製は読み上げない
  clone.classList.add("product__flier");
  Object.assign(clone.style, { left: `${from.left}px`, top: `${from.top}px`, width: `${from.width}px`, height: `${from.height}px` });
  document.body.append(clone);
  const path = `M0,0 C${dx * ARC[0][0]},${dy * ARC[0][1]} ${dx * ARC[1][0]},${dy * ARC[1][1]} ${dx},${dy}`;
  gsap
    .timeline({ onComplete: () => clone.remove() })
    .to(clone, { motionPath: { path }, duration: FLIGHT, ease: "power1.inOut" }, 0)
    // 大きさは等速で縮める。早く縮めると小さすぎて目で追えない
    .to(clone, { scale: END_SCALE, duration: FLIGHT, ease: "none" }, 0)
    .to(clone, { autoAlpha: 0, duration: 0.12, ease: "none" }, FLIGHT - 0.12)
    .call(onArrive, null, FLIGHT);
}

document.querySelectorAll(".product").forEach((product) => {
  const image = product.querySelector(".product__image");
  const button = product.querySelector(".product__add");
  const labels = product.querySelector(".product__labels");
  // 色は CSS が正。GSAP は var(--name) のままでは補間できないので、値にして渡す
  const styles = getComputedStyle(product);
  const [ink, pressed, success] = ["--ink", "--pressed", "--success"].map((name) => styles.getPropertyValue(name).trim());

  // ボタンは一瞬沈めてから成功の色にし、文言の列を 1 段上げる。少ししたら戻す(押し直すと頭から)
  const added = gsap
    .timeline({ paused: true })
    .fromTo(button, { backgroundColor: ink }, { backgroundColor: pressed, duration: time(0.08), ease: "none" }, 0)
    .to(button, { backgroundColor: success, duration: time(0.3), ease: "none" }, 0.1)
    .fromTo(labels, { yPercent: 0 }, { yPercent: -50, duration: time(0.42), ease: "power3.inOut" }, 0.08)
    .to(button, { backgroundColor: ink, duration: time(0.3), ease: "none" }, 2.3)
    .to(labels, { yPercent: 0, duration: time(0.42), ease: "power3.inOut" }, 2.3);

  button.addEventListener("click", () => {
    count += 1;
    cart.setAttribute("aria-label", `カート(${count}点)`);
    // 飛ぶ動きは目で見る人向け。読み上げには、押した時点で結果を届ける
    status.textContent = `${product.dataset.name}をカートに入れました。カートの商品は${count}点です。`;
    added.restart();
    if (reduceMotion) bump();
    else fly(image, bump);
  });
});

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

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