🛒

放物線を描くカートインアニメーションの実装

2025/02/26に公開

はじめに

ECサイトの開発において、「商品をカートに入れるボタン」のクリック時にアニメーションを加えることで、リッチな印象を与えることができます。

ecforceでTypeScriptを使ったカートインアニメーションを実装しました。(まだブラッシュアップできる余地があると思いますが。。)
本記事では、その設計と実装手順を詳しく解説します。

開発環境

  • ECカートシステム: ecforce
  • フロントエンド: TypeScript
  • アニメーション: CSS & JavaScript (ベジェ曲線)

実装のイメージ

アニメーションの流れは以下の通りです。
1.「カートに入れる」ボタンをクリック
2. ボール(丸型の要素)が生成され、カートアイコンに向かって放物線を描く
3. カートアイコンに到達した瞬間、ボールが拡大しながら消える
4. カートアイコンに表示される数量が増加する

その他の挙動

ボタンのテキストが 「処理中」→「カートに入れました」 に変化

留意点

ecforceでは「カートに入れる」ボタンをクリックすると、通常はカートページにリダイレクトされる仕様になっています。しかし、本実装ではfetchを用いた非同期処理により、ページ遷移せずにカートアニメーションを実行します。

カートインアニメーションの実装

1.型定義

まずは、アニメーションに必要な座標やパラメータの型を定義します。

type Position = {
  x: number;
  y: number;
};
type AnimationParams = {
  startPos: Position;
  endPos: Position;
  controlPoint: Position;
  duration: number;
};

Position:2D座標を表す型
AnimationParams:アニメーションで使用する開始位置、終了位置、制御点、時間を定義

2.カートとボタンの位置を取得

getBoundingClientRect()メソッドを使い、ボタンやカートアイコンの位置を取得する関数を作成します。

function getElementPosition(element: HTMLElement): Position {
  // 1. 要素のサイズや画面上の位置情報を取得
  const rect = element.getBoundingClientRect();
  // 2. x座標、y座標を計算して返す
  return {
    x: rect.left + rect.width / 2,
    y: rect.top + rect.height / 2
  };
}

3.アニメーションのための制御点を計算

function createParabolaControlPoint(start: Position, end: Position): Position {
  // x座標は、開始位置と終了位置の中間
  const midX = (start.x + end.x) / 2;
  // 制御点の相対的なy座標を計算
  const heightOffset = Math.min(200, Math.abs(end.x - start.x) * 0.5);

  return {
    x: midX,
    // 開始位置と終了位置のうち高い座標から制御点のy座標を決定
    y: Math.min(start.y, end.y) - heightOffset
  };
}

ここで、放物線を描くために必要なベジェ曲線と制御点について簡単に解説します。

💡ベジェ曲線とは

アニメーション図と見比べながら、読んでいただけると理解しやすいかと思います。

  1. 3つの点(P0、P1、P2)があります ※P1が制御点
  2. P0からP1まで、P1からP2をオレンジの点が同じ速さで左から右へ移動します
  3. この2つのオレンジの点を結ぶ線があります
  4. その線上を赤い点が同じ時間で移動します
  5. この赤い点が通った道筋が、ベジェ曲線になります

▼参考記事
https://ja.javascript.info/bezier-curve

4.ボールを放射線に沿ってアニメーションさせる

function animateAlongParabola(element: HTMLElement, params: AnimationParams): Promise<void> {
  return new Promise((resolve) => {
    const startTime = performance.now();
    // アニメーションの進捗計算の関数
    function update(currentTime: number) {
      const elapsed = currentTime - startTime;
      // アニメーション全体の進行度を0〜1の範囲で表す
      const progress = Math.min(elapsed / params.duration, 1);
      if (progress < 1) {
        // 2次ベジェ曲線で座標を求める公式に当てはめる
        const t = progress;
        const x = Math.pow(1 - t, 2) * params.startPos.x +
                  2 * (1 - t) * t * params.controlPoint.x +
                  Math.pow(t, 2) * params.endPos.x;
        const y = Math.pow(1 - t, 2) * params.startPos.y +
                  2 * (1 - t) * t * params.controlPoint.y +
                  Math.pow(t, 2) * params.endPos.y;
        // ボールの位置の適用
        element.style.left = `${x - 34}px`; // 適宜変更
        element.style.top = `${y - 34}px`; // 適宜変更
        requestAnimationFrame(update);
      } else {
        // アニメーション完了時の処理
     // 移動中に表示していたボール
        const movingball = element.querySelector('.js-movingball') as SVGElement;
     // 到着後に表示するボール
        const arrivedball = element.querySelector('.js-arrivedball') as SVGElement;
        movingSvg.style.display = 'none';
        arrivedSvg.style.display = 'block';
        arrivedSvg.style.opacity = '0';
        arrivedSvg.style.transform = 'scale(0.5)';
        arrivedSvg.style.transition = 'all 0.5s ease-out';
        // 到着後の拡大アニメーション
        requestAnimationFrame(() => {
          arrivedSvg.style.opacity = '1';
          arrivedSvg.style.transform = 'scale(1.5)';
        });
        setTimeout(() => {
          element.remove();
          resolve();
        }, 700);
      }
    }
    // 初回はここからupdate関数が呼び出される
    requestAnimationFrame(update);
  });
}

5.ボール用のDOM要素を生成する

function createAnimatedElement(startPos: Position): HTMLElement {
  const element = document.createElement('div');
  element.style.position = 'fixed';
  element.style.width = '68px'; // 適宜変更
  element.style.height = '68px'; // 適宜変更
  element.style.pointerEvents = 'none';
  element.style.zIndex = '9999';
  element.style.left = `${startPos.x - 34}px`; // 適宜変更
  element.style.top = `${startPos.y - 34}px`; // 適宜変更

  const movingball = `
    <!-- 移動中のボール -->
  `;

  const arriveball = `
    <!-- 到着後のボール -->
  `;

  element.innerHTML = movingball + arrivedball;
  return element;
}

7.カートの数量を更新

ecforceのデフォルトの挙動ではカート内の商品数量が変更されても、ページをリロードしない限りヘッダーのカートアイコンの数量表示は更新されません。
以下のupdateCartQuantity関数で見た目上の数値をフロント側で即時更新し、実際のカート情報はページロード時に同期されます。

function updateCartQuantity(): void {
  const quantityElement = document.getElementById('header-order-quantity');
  if (quantityElement) {
    const currentQuantity = parseInt(quantityElement.textContent || '0', 10);
    const newQuantity = currentQuantity + 1;
    quantityElement.textContent = newQuantity.toString();
  }
}

カート数量の取得はCMS側に任せ、フロントでは一時的な見た目の変更のみにとどめています。その理由は以下の通りです。

  • ecforceのカート情報を取得するAPIの仕様が公開されていないため、フロント側で直接データを取得できない
  • ページロード時に最新のデータと同期されるため、即時のAPI取得は不要

8.ボタンのテキストを更新する

function updateButtonText(button: HTMLButtonElement, text: string) {
  button.textContent = text;
}

9.カートアニメーションの初期化

この関数では、以下の処理を行います。

  1. フォームを送信し、商品をカートに追加
  2. ボールがカートアイコンに向かって飛ぶアニメーションを実行
  3. ボタンのテキストを変更し、カートアイコンの数量を即時更新
  4. リロード時にサーバーの正しい数量と同期
function initializeCartAnimation(): void {

  document.addEventListener('click', async (event) => {
    if (!event.target) return;

    // クリックされた要素がカート追加ボタンかどうか確認
    const target = event.target as Element;
    const submitButton = target.closest('[data-cart-submit]') as HTMLButtonElement;
    if (!submitButton) return;

    event.preventDefault(); // デフォルトのフォーム送信を防止
    event.stopPropagation(); // 親要素へのイベント伝播を防止

    // カートアイコンの要素を取得
    const cartButton = document.querySelector('[data-cart-nav]') as HTMLElement;
    if (!cartButton) {
      return;
    }

    submitButton.disabled = true; // 連続クリックを防止
    updateButtonText(submitButton, "処理中");

    try {
      // フォームの取得
      const form = submitButton.closest("form"); // ボタンが属するフォームを取得
      if (!form) throw new Error("フォームが見つかりません");

      // フォームデータの準備
      const formData = new FormData(form);
      const actionUrl = form.action; // actionが未設定ならエラーを投げる
      if (!actionUrl) throw new Error("フォームのactionが設定されていません");

      // フォームのPOST送信 (ページ遷移なし)
      const response = await fetch(actionUrl, {
        method: "POST",
        body: formData
      });

      // サーバーのレスポンスチェック
      if (!response.ok) {
        throw new Error("フォームの送信に失敗しました");
      }

      // アニメーション開始のための座標を取得
      const startPos = getElementPosition(submitButton);
      const endPos = getElementPosition(cartButton);
      const controlPoint = createParabolaControlPoint(startPos, endPos);
      const animatedElement = createAnimatedElement(startPos);
      document.body.appendChild(animatedElement);

      const params: AnimationParams = {
        startPos,
        endPos,
        controlPoint,
        duration: 1000  // アニメーション時間を1秒
      };

      // ボールのアニメーションを実行
      await animateAlongParabola(animatedElement, params);

      // 見た目上カートの数量を増やす (リロードすると実際の値に戻る)
      const dummyQuantity = 1;
      updateCartQuantity();
      updateButtonText(submitButton, "カートに入れました");

    } catch (error) {
      console.error("カート追加エラー:", error);
      updateButtonText(submitButton, "カートへ入れる"); // エラー発生時にボタンを元の状態に戻す
    } finally {
      submitButton.disabled = false; // ボタンを再びクリック可能にする
    }
  });
}

initializeCartAnimationをエクスポートして使います。

まとめ

今回はecforce×TypeScriptで、カートイン時のアニメーションを実装しました。また余談ですが、これまで耳にしてはいたがよく分からなかったベジェ曲線について理解が深まりました。レビューやご意見いただけると幸いです。最後まで読んでくださり、ありがとうございました。

参考記事

https://sterfield.co.jp/blog/13313/?utm_content=buffer87295&utm_medium=social&utm_source=twitter.com&utm_campaign=buffer

Discussion