本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
オーバーレイ

カートに入れると右からカートのパネルが入り、追加した行が光る

デモ

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

仕様

動作
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 で、狭い画面では画面の幅いっぱいに広げます。ネットショップの商品一覧や商品詳細で、ページを移らずにカートの中身を確かめてもらいたいときに向いています。ボタンの側で商品がカートへ飛ぶ動きと組み合わせても、よく合います。

コード

HTML
<!-- 商品のボタン。カートの行に出す中身を 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>

CSS
.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; }

JavaScript
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" });
});

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

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