デモ
デモを読み込めませんでした。
仕様
- 用途
- ボタン
- 動作
- 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 の商品一覧・商品詳細・おすすめ枠の「カートに入れる」に向いています。
コード
<!-- ヘッダーのカート。数は 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>
.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);
}
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);
});
});