デモ
デモを読み込めませんでした。
仕様
- 用途
- オーバーレイ
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
「カートに入れる」を押すと、後ろのページが暗くなり、カートのパネルが右の外から滑り込みます。パネルの一覧では、下の行から順に 1 行分ずつ押し下げられ、空いた先頭へ今入れた商品の行が上から差し込まれて、地が一度淡い緑に光ります。見出しの点数が替わり、小計は前の金額から新しい金額まで数えて変わります。同じ商品をもう一度入れたときは、行を足さずに数量と金額を替え、その行だけを光らせます。
パネルは 0.7 秒の expo.out で入り、動きの大半を最初の 0.2 秒で済ませてから、ゆっくり止まります。一覧を動かし始めるのは入り始めの 0.3 秒後で、パネルがほぼ止まってから行が動くので、目が迷いません。行は 0.5 秒の power3.inOut で、下の行から 0.05 秒ずつずらして押し下げ、最後に入れた行を差し込みます。光は 0.12 秒で点け、約 1 秒保ってから 1.2 秒かけて消します。
差し込む動きは、先に行を一覧の先頭へ入れて(prepend)から、元の位置との差を from に与えて 0 へ戻す形で書きます。先に動かしてから入れると、入れた瞬間に下の行が一段飛びます。押し下げを下の行から始めるのは、上の行から動かすと、動き出した行がまだ止まっている下の行に一瞬重なるからです。行には面の色を塗っておき、動く途中に後ろの行が透けないようにします。
パネルはダイアログの要素に入れて showModal で開くので、焦点の閉じ込めと、閉じたあとの焦点の戻しを任せられます。Esc は既定だとその場で消えるので、cancel を止めて同じ動きで閉じます。行が増えたことは光だけでは伝わらないので、「リネントートをカートに入れました。カートの商品は3点です。」のような文を status の領域に書いて、読み上げにも届けます。
光の色は、成功の色を面の色に 2 割ほど混ぜた不透明な色にしています。濃くすると、行の文字や金額が読みにくくなります。パネルの幅は 448px で、狭い画面では画面の幅いっぱいに広げます。ネットショップの商品一覧や商品詳細で、ページを移らずにカートの中身を確かめてもらいたいときに向いています。ボタンの側で商品がカートへ飛ぶ動きと組み合わせても、よく合います。
コード
<!-- 商品のボタン。カートの行に出す中身を data-* に持たせる(同じ data-id なら、行を足さずに数量を増やす) -->
<button class="cart-add" type="button" data-cart-add data-id="linen-tote" data-name="リネントート"
data-variant="ナチュラル" data-price="4400" data-image="/img/linen-tote.jpg">カートに入れる</button>
<!-- ヘッダーのカートのリンク。スクリプトが動かないときは、カートのページへ移る -->
<a class="cart-link" href="/cart/" data-cart-open>カート</a>
<!-- showModal() で開く <dialog> には、焦点の閉じ込め・背面の締め出し・Esc・焦点の戻しが付いてくる -->
<dialog class="cart" aria-labelledby="cart-title">
<div class="cart__scrim" data-cart-close></div>
<section class="cart__panel">
<div class="cart__head">
<h2 class="cart__title" id="cart-title">カート</h2>
<span class="cart__count" data-cart-count>2点</span>
<button class="cart__close" type="button" aria-label="カートを閉じる" data-cart-close>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18" /></svg>
</button>
</div>
<ul class="cart__list">
<li class="cart__row" data-id="ceramic-mug" data-price="2800" data-qty="1">
<img class="cart__image" src="/img/ceramic-mug.jpg" alt="" width="64" height="64">
<p class="cart__name">陶器のマグカップ</p>
<p class="cart__meta">ホワイト・数量<span data-qty>1</span></p>
<p class="cart__price" data-line>¥2,800</p>
</li>
<li class="cart__row" data-id="wool-throw" data-price="9300" data-qty="1">
<img class="cart__image" src="/img/wool-throw.jpg" alt="" width="64" height="64">
<p class="cart__name">ウールのひざ掛け</p>
<p class="cart__meta">セージ・数量<span data-qty>1</span></p>
<p class="cart__price" data-line>¥9,300</p>
</li>
</ul>
<div class="cart__foot">
<p class="cart__sum">小計(税込)<strong data-cart-total>¥12,100</strong></p>
<p class="cart__note">送料は、ご購入手続きで計算されます。</p>
<a class="cart__button cart__button--fill" href="/checkout/">レジに進む</a>
<button class="cart__button" type="button" data-cart-close>買い物を続ける</button>
</div>
<p class="cart__status" role="status"></p>
</section>
</dialog>
<!-- 足す行のひな形 -->
<template data-cart-row>
<li class="cart__row">
<img class="cart__image" alt="" width="64" height="64">
<p class="cart__name"></p>
<p class="cart__meta"><span data-variant></span>・数量<span data-qty>1</span></p>
<p class="cart__price" data-line></p>
</li>
</template>
.cart {
--ink: #262626; /* 文字と線 */
--mute: #6b6b6b; /* 補足の文字 */
--surface: #ffffff; /* パネルと行の地 */
--line: rgba(38, 38, 38, 0.12); /* 区切りの罫線 */
--flash: #dce8e0; /* 入れた行が光るときの地(成功の色を 20% 混ぜた不透明な色) */
--scrim: rgba(17, 17, 17, 0.45); /* 後ろのページを暗くする幕 */
/* dialog を画面いっぱいに広げる(既定の余白・枠・最大の大きさを外す) */
width: 100%;
max-width: none;
height: 100%;
max-height: none;
margin: 0;
padding: 0;
border: 0;
background: none;
overflow: hidden;
color: var(--ink);
}
.cart::backdrop { background: none; }
.cart__scrim { position: absolute; inset: 0; background-color: var(--scrim); }
/* 開いているあいだは後ろのページを止め、スクロールバーの幅を先に空けておく(ページが横にずれない) */
html:has(.cart[open]) { overflow: hidden; scrollbar-gutter: stable; }
.cart__panel {
position: absolute;
inset: 0 0 0 auto;
display: flex;
flex-direction: column;
width: min(448px, 100%);
background-color: var(--surface);
box-shadow: inset 1px 0 0 var(--line); /* 影は付けず、左の縁に罫線 */
}
.cart__head { display: flex; flex: none; align-items: center; gap: 12px; height: 48px; padding: 32px 32px 31px; box-shadow: inset 0 -1px 0 var(--line); }
.cart__title { margin: 0; font-size: 24px; line-height: 1; letter-spacing: -0.01em; }
.cart__count { color: var(--mute); font-size: 14px; letter-spacing: 0.04em; }
/* 閉じるボタンと副ボタンは、文字の色の線。アイコンだけのボタンは線の丸 */
.cart__close,
.cart__button {
display: grid;
place-items: center;
padding: 0;
border: 0;
box-shadow: inset 0 0 0 1px var(--ink);
background: none;
color: var(--ink);
cursor: pointer;
}
.cart__close { width: 40px; height: 40px; margin-left: auto; border-radius: 50%; }
.cart__close svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 3; stroke-linecap: round; }
/* 行は地を塗っておく(押し下げる途中に下の行が透けない) */
.cart__list { flex: 1; margin: 0; padding: 0; overflow-y: auto; overscroll-behavior: contain; list-style: none; }
.cart__row {
display: grid;
grid-template-columns: 64px 1fr auto;
align-content: center;
column-gap: 16px;
row-gap: 8px;
height: 64px;
padding: 24px 32px;
background-color: var(--surface);
box-shadow: inset 0 -1px 0 var(--line);
}
.cart__row > p { margin: 0; line-height: 1; letter-spacing: 0.04em; }
.cart__image { grid-row: span 2; border-radius: 4px; background-color: #e9e4dc; object-fit: cover; }
.cart__name { align-self: end; font-size: 16px; font-weight: 700; }
.cart__meta { grid-column: 2; color: var(--mute); font-size: 13px; }
.cart__price { grid-area: 1 / 3; align-self: end; font-size: 16px; font-weight: 600; font-variant-numeric: tabular-nums; }
.cart__foot { flex: none; display: grid; gap: 12px; padding: 24px 32px 32px; box-shadow: inset 0 1px 0 var(--line); }
.cart__foot > p { margin: 0; letter-spacing: 0.04em; }
.cart__sum { display: flex; align-items: baseline; justify-content: space-between; font-size: 14px; font-weight: 600; }
.cart__sum strong { font-size: 24px; font-variant-numeric: tabular-nums; }
.cart__foot > .cart__note { margin-bottom: 20px; color: var(--mute); font-size: 13px; }
.cart__button {
height: 48px;
border-radius: 999px;
font: inherit;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.04em;
text-decoration: none;
}
.cart__button--fill { background-color: var(--ink); color: var(--surface); }
.cart__status { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.cart :focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
/* 商品のボタン(主ボタン) */
.cart-add { height: 48px; padding: 0 32px; border: 0; border-radius: 999px; background-color: #262626; color: #ffffff; font: inherit; font-size: 14px; font-weight: 600; letter-spacing: 0.04em; cursor: pointer; }
.cart-add:focus-visible { outline: 2px solid #262626; outline-offset: 4px; }
import { gsap } from "gsap";
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SPEED = reduceMotion ? 20 : 1; // 動きを減らす設定では、ほぼ一瞬で切り替える
const MAX_QTY = 9;
const cart = document.querySelector(".cart");
const scrim = cart.querySelector(".cart__scrim");
const panel = cart.querySelector(".cart__panel");
const list = cart.querySelector(".cart__list");
const count = cart.querySelector("[data-cart-count]");
const total = cart.querySelector("[data-cart-total]");
const status = cart.querySelector("[role=status]");
const template = document.querySelector("[data-cart-row]");
// GSAP は var() のままでは色を補間できないので、CSS 変数の値を読んで渡す
const color = (name) => getComputedStyle(cart).getPropertyValue(name).trim();
const yen = (value) => `¥${Math.round(value).toLocaleString("ja-JP")}`;
const rowsOf = () => [...list.children];
const sum = () => rowsOf().reduce((all, row) => all + row.dataset.price * row.dataset.qty, 0);
const items = () => rowsOf().reduce((all, row) => all + Number(row.dataset.qty), 0);
let motion = null;
const run = (timeline) => {
motion?.progress(1).kill(); // 前の動きは終わった形にしてから次へ(数え途中の小計を残さない)
motion = timeline.timeScale(SPEED);
return motion;
};
// 開いていなければ開く。開く動きのあとに続ける時刻を返す
function open(tl) {
if (cart.open) return 0;
cart.showModal(); // 焦点は閉じるボタンへ移り、閉じるとそのとき押したボタンへ戻る
tl.fromTo(scrim, { opacity: 0 }, { opacity: 1, duration: 0.4, ease: "power2.out" }, 0)
.fromTo(panel, { xPercent: 100 }, { xPercent: 0, duration: 0.7, ease: "expo.out" }, 0);
return 0.3;
}
function close() {
run(gsap.timeline({ onComplete: () => cart.close() }))
.to(panel, { xPercent: 100, duration: 0.45, ease: "power3.inOut" }, 0)
.to(scrim, { opacity: 0, duration: 0.4, ease: "power1.inOut" }, 0);
}
function add({ id, name, variant, price, image }) {
const before = sum();
const tl = run(gsap.timeline());
const at = open(tl);
let row = list.querySelector(`[data-id="${id}"]`);
if (row) {
row.dataset.qty = Math.min(Number(row.dataset.qty) + 1, MAX_QTY);
} else {
row = template.content.firstElementChild.cloneNode(true);
Object.assign(row.dataset, { id, price, qty: 1 });
Object.assign(row.querySelector("img"), { src: image });
row.querySelector(".cart__name").textContent = name;
row.querySelector("[data-variant]").textContent = variant;
const below = rowsOf();
list.prepend(row); // 先に入れてから、元の位置との差で動かす
list.scrollTop = 0;
// 下の行から順に 1 行分押し下げ、空いた先頭へ上から差し込む
tl.fromTo(below.reverse(), { y: -row.offsetHeight }, { y: 0, duration: 0.5, ease: "power3.inOut", stagger: 0.05 }, at)
.fromTo(row, { yPercent: -100 }, { yPercent: 0, duration: 0.5, ease: "power3.inOut" }, at + below.length * 0.05);
}
const qty = Number(row.dataset.qty);
const counter = { value: before };
tl.add(() => {
row.querySelector("[data-qty]").textContent = qty;
row.querySelector("[data-line]").textContent = yen(price * qty);
count.textContent = `${items()}点`;
status.textContent = `${name}をカートに入れました。カートの商品は${items()}点です。`;
}, at + 0.25)
// 小計は前の金額から数えて変える
.to(counter, { value: sum(), duration: 0.8, ease: "power2.out", onUpdate: () => (total.textContent = yen(Math.round(counter.value / 10) * 10)) }, at + 0.55)
// 入れた行の地を一度光らせ、少し保ってから戻す
.fromTo(row, { backgroundColor: color("--surface") }, { backgroundColor: color("--flash"), duration: 0.12, ease: "none" }, at + 0.6)
.to(row, { backgroundColor: color("--surface"), duration: 1.2, ease: "power1.in", clearProps: "backgroundColor" }, at + 1.62);
}
document.querySelectorAll("[data-cart-add]").forEach((button) => {
button.addEventListener("click", () => add({ ...button.dataset, price: Number(button.dataset.price) }));
});
document.querySelectorAll("[data-cart-open]").forEach((link) => {
link.addEventListener("click", (event) => {
event.preventDefault();
open(run(gsap.timeline()));
});
});
cart.querySelectorAll("[data-cart-close]").forEach((node) => node.addEventListener("click", close));
// Esc は既定だとその場で閉じるので、いったん止めて同じ動きで閉じる
cart.addEventListener("cancel", (event) => {
event.preventDefault();
close();
});
// Esc を続けて押すと、動きを待たずに閉じることがあるので、ここで動きを終わらせておく
cart.addEventListener("close", () => {
motion?.progress(1).kill();
gsap.set([panel, scrim], { clearProps: "transform,opacity" });
});