はじめに
この記事はand factory.inc Advent Calendar 2024 22日目の記事です![]()
and factoryでエンジニアをしている@y-okuderaです!
以前はiOSアプリを中心に開発していましたが、最近はバックエンドやWebフロントエンド開発にチャレンジしています。
今回は、フロントエンドでアニメーションの実装をする際にframer-motionが便利だったので紹介します。
Framer Motionについて
Framer Motion は、React 向けに設計された強力で使いやすいアニメーションライブラリです。滑らかなアニメーションやインタラクションを簡単に実装でき、モーションデザインを効率的に行うことができます。
主な特徴
- 簡潔な API
 
React コンポーネントとして提供され、motion.div や motion.span のように、通常の HTML 要素にアニメーションを追加することができます。
- アニメーションの設定
 
initial、animate、exitなどアニメーションの開始値や終了値を指定できます。
今回示す例ではinitial、animateを利用します。
// initialで開始値、animateで終了値を指定
<motion.div initial={{ x: 0 }} animate={{ x: 100 }} />
また、transitionでアニメーションの遷移時間やイージングなども簡単に設定することができます。
// transitionで遷移時間、イージングを設定
<motion.div
  animate={{ scale: 1.5 }}
  transition={{ duration: 0.5, ease: "easeInOut" }}
/>
- 複雑なアニメーション対応
 
配列を使って中間地点の指定をする形でキーアニメーションを実装できます。
その他にも便利な機能がたくさん提供されています。
タロットカード風のシャッフルアニメーションを実装する
アニメーションの実装となると、大体これを題材にしがちです![]()
(過去にFlutterで類似のアニメーションを実装しています)
今回は、Next.jsでFramer Motionを利用して、カードをシャッフルしたいと思います!
アニメーションの流れ
- 
初期状態:initial
カードの束が1つあります。クリックするとシャッフルアニメーションが開始します。 - 
シャッフルアニメーション:shuffling
カードがシャッフルされます。3秒間アニメーションして3つの束に収束します。 - 
アニメーション後:piled
3つの束があります。クリックするとアニメーションしながら初期状態の1つの束に戻ります。 
完成イメージ
カードのデザインがうさぎなのでタロット感が全然ありませんね...![]()
事前準備
まずは、シャッフルするカードのイメージを用意します。
今回もいらすとやの画像を使わせていただきましょう![]()
ライブラリのインストール
Next.jsプロジェクトを作成し、Framer Motionをインストールします。
(プロジェクト作成の詳細については割愛します。)
npm install framer-motion
コンポーネントの実装
アニメーションをするコンポーネントを実装していきます。
インターフェースの定義
Propsを定義していきます。
※ 今回はinterfaceで定義していますが、typeでも問題ないです。
お好みの方法で定義してください。
interface Card {
  id: number;
  image: string;
}
interface ShuffleAnimationProps {
  cards: Card[];
  onAnimationEnd: () => void;
  onCardClick: (id: number) => void;
  animationState: "initial" | "shuffling" | "piled";
}
アニメーション完了時の処理、カードクリック時の処理をコンポーネントを利用する側で定義できるように関数をそれぞれonAnimationEndとonCardClickで渡せるようにしています。
animationStateについては後述しますが、「初期状態」・「シャッフル中」・「積まれた状態(アニメーション後)」の3つの状態を定義しています。
アニメーションの状態の実装
冒頭に記載した例ではanimateに直接値を設定していましたが、Variantsでアニメーションを定義します。Variantsを定義することで、initialやanimateなどアニメーションターゲットを定義できる場所であればどこでも、ラベルで参照できるようになります。
- 初期状態:initial
 
カードの束が1つあります。クリックするとシャッフルアニメーションが開始します。
初期状態を示すinitialラベルは、x, y座標は0で、回転も加えないのでrotateも0とします。
const variants = {
    initial: {
      x: 0,
      y: 0,
      rotate: 0,
    },
  };
...
<motion.div
    ...
    initial="initial"
    ...
参考
https://motion.dev/docs/react-animation#variants
I love using variants alongside React state – just pass your state to animate, and now you've got a tidy place to define all your animation targets!
Reactのstateと一緒にvariantsを使うのが大好きです。stateをそのままanimateに渡すだけで、すべてのアニメーションターゲットをすっきりと整理して定義できるようになります!
initial以外のvariantsも定義していきましょう。
- シャッフルアニメーション:shuffling
 
カードがシャッフルされます。3秒間アニメーションして3つの束に収束します。
シャッフルアニメーション中のshufflingラベルでは、x, y座標、回転ともに範囲指定したランダムな値を設定します。連続で複数の移動をさせたいので、x, y, rotateそれぞれ配列で複数の値を設定します。
Math.random() は、0以上1未満の範囲で浮動小数点の擬似乱数を返すので、掛け算、負の値も範囲に含める場合は引き算もして、範囲指定したランダムな値を定義します。
範囲指定の具体的な値については、実際にアニメーションを確認しながらそれらしく動くように微調整をしました。
シャッフルアニメーションの最終的な移動先は、横並びの3つの束としたいので、x は3つの値、y, rotateは0になるように設定します。
variantを使用する際にcardsのindexを渡すようにして、indexごとに最終的な移動先が決まるようにしました。
const piles = [0, 1, 2]; // 最終的に3つの束に分ける
const variants = (index: number): Variants => ({
    initial: {
      x: 0,
      y: 0,
      rotate: 0,
    },
    shuffling: {
      x: [
        Math.random() * 200 - 100, // -100px~100px
        Math.random() * 300 - 150, // -150px~150px
        Math.random() * 200 - 100, // -100px~100px
        piles[index % 3] * 150 - 150, // 最終的に -150px, 0px, 150pxの3つに収束
      ],
      y: [
        Math.random() * 100 - 50, // -50px~50px
        Math.random() * 150 - 75, // -75px~75px
        Math.random() * 100 - 50, // -50px~50px
        0, // 最終的に0に収束
      ],
      rotate: [
        Math.random() * 30 - 15, // -15度~15度
        Math.random() * 45 - 20, // -20度~25度
        Math.random() * 30 - 15, // -15度~15度
        0, // 最終的に0に収束
      ],
    },
  });
- アニメーション後:piled
 
3つの束があります。クリックするとアニメーションしながら初期状態の1つの束に戻ります。
積まれた状態(アニメーション後)は、シャッフルアニメーションの最終的な移動先と同じ値になります。
const piles = [0, 1, 2]; // 最終的に3つの束に分ける
const variants = (index: number): Variants => ({
    initial: {
      x: 0,
      y: 0,
      rotate: 0,
    },
    shuffling: {
      x: [
        Math.random() * 200 - 100,
        Math.random() * 300 - 150,
        Math.random() * 200 - 100,
        piles[index % 3] * 150 - 150,
      ],
      y: [
        Math.random() * 100 - 50,
        Math.random() * 150 - 75,
        Math.random() * 100 - 50,
        0,
      ],
      rotate: [
        Math.random() * 30 - 15,
        Math.random() * 45 - 20,
        Math.random() * 30 - 15,
        0,
      ],
    },
    piled: {
      x: piles[index % 3] * 150 - 150,
      y: 0,
      rotate: 0,
    },
  });
アニメーションの設定の実装
続いて、transitionでアニメーションの設定をします。
今回は、アニメーションの長さ、遅延、イージングの設定をします。
アニメーションの状態がシャッフルアニメーション中かそれ以外かで設定を変更するため、フラグを用意し値を設定します。
遅延が大きすぎると目立ってしまうので、Math.exp()で指数関数的に調整しました。
  const isShuffling = animationState === "shuffling";
  const transition = (index: number) => ({
    duration: isShuffling ? 3 : 0.5, // シャッフルアニメーションは3秒
    delay: isShuffling ? index * 0.05 * Math.exp(-index / 20) : 0, // シャッフルアニメーション時は遅延を指数関数的に調整
    ease: "easeInOut",
  });
アニメーション完了時の処理を実装
アニメーションが完了したときは、onAnimationCompleteでコールバックを受け取ることができます。
今回は、複数のカードをアニメーションさせるので、最後のカードのアニメーションが終わったらonAnimationEnd を呼び出すようにします。
  const handleAnimationComplete = (index: number) => {
    if (animationState !== "shuffling") return;
    // 最後のカードのアニメーションが終わったら `onAnimationEnd` を呼び出す
    if (index === totalCards - 1) {
      onAnimationEnd();
    }
  };
  ...
  <motion.div
    ...
    onAnimationComplete={() => handleAnimationComplete(index)}
    ...
  >
コンポーネントの全体像
一部説明を省いていますが、コンポーネントの全体像は以下のような感じです。
import Component from "./component";
const ShuffleAnimation = ({
  cards,
  onAnimationEnd,
  onCardClick,
  animationState,
}: ShuffleAnimationProps) => {
  return (
    <Component
      cards={cards}
      onAnimationEnd={onAnimationEnd}
      onCardClick={onCardClick}
      animationState={animationState}
    />
  );
};
export default ShuffleAnimation;
import { motion, Variants } from "framer-motion";
import styles from "./styles.module.css";
const Component = ({
  cards,
  onAnimationEnd,
  onCardClick,
  animationState,
}: ShuffleAnimationProps) => {
  const totalCards = cards.length;
  const piles = [0, 1, 2]; // 最終的に3つの束に分ける
  const handleAnimationComplete = (index: number) => {
    if (animationState !== "shuffling") return;
    // 最後のカードのアニメーションが終わったら `onAnimationEnd` を呼び出す
    if (index === totalCards - 1) {
      onAnimationEnd();
    }
  };
  const variants = (index: number): Variants => ({
    initial: {
      x: 0,
      y: 0,
      rotate: 0,
    },
    shuffling: {
      x: [
        Math.random() * 200 - 100, // -100px~100px
        Math.random() * 300 - 150, // -150px~150px
        Math.random() * 200 - 100, // -100px~100px
        piles[index % 3] * 150 - 150, // 最終的に -150px, 0px, 150pxの3つに収束
      ],
      y: [
        Math.random() * 100 - 50, // -50px~50px
        Math.random() * 150 - 75, // -75px~75px
        Math.random() * 100 - 50, // -50px~50px
        0, // 最終的に0に収束
      ],
      rotate: [
        Math.random() * 30 - 15, // -15度~15度
        Math.random() * 45 - 20, // -20度~25度
        Math.random() * 30 - 15, // -15度~15度
        0, // 最終的に0に収束
      ],
    },
    piled: {
      x: piles[index % 3] * 150 - 150,
      y: 0,
      rotate: 0,
    },
  });
  const isShuffling = animationState === "shuffling";
  const isClickable = !isShuffling;
  const transition = (index: number) => ({
    duration: isShuffling ? 3 : 0.5, // シャッフルアニメーションは3秒
    delay: isShuffling ? index * 0.05 * Math.exp(-index / 20) : 0, // シャッフルアニメーション時は遅延を指数関数的に調整
    ease: "easeInOut",
  });
  return (
    <div className={styles.shuffleContainer}>
      {cards.map((card, index) => (
        <motion.div
          animate={animationState}
          className={`${styles.card} ${isClickable ? styles.clickable : ""}`}
          initial="initial"
          key={card.id}
          onClick={() => isClickable && onCardClick(card.id)}
          onAnimationComplete={() => handleAnimationComplete(index)}
          transition={transition(index)}
          variants={variants(index)}
        >
          <div
            className={styles.cardImage}
            style={{ backgroundImage: `url(${card.image})` }}
          ></div>
        </motion.div>
      ))}
    </div>
  );
};
export default Component;
.shuffleContainer {
  position: relative;
  width: 100%;
  height: 400px;
  display: flex;
  justify-content: center;
  align-items: center;
  overflow: visible;
}
.card {
  position: absolute;
  width: 100px;
  height: 150px;
  perspective: 1000px;
}
.cardImage {
  width: 100%;
  height: 100%;
  background-size: cover;
  background-position: center;
  border-radius: 8px;
  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
  transform-origin: center center;
}
.clickable {
  cursor: pointer;
}
コンポーネントを利用するfeatureの実装
コンポーネントを利用する側を実装します。
pageに"use client"をつけたくないので、src/app/features/tarotCard実装をします。
こちらは、cardsの生成とonAnimationEnd, onCardClickのときのstateの更新くらいしか実行しないのでさらっと全体像だけ記載します。
import Component from "./component";
const TarotCard = () => {
  return <Component />;
};
export default TarotCard;
"use client";
import ShuffleAnimation from "@/components/ShuffleAnimation";
import { useEffect, useState } from "react";
const Component = () => {
  const [animationState, setAnimationState] = useState<
    "initial" | "shuffling" | "piled"
  >("initial");
  const cardCount = 78;
  const [cards] = useState<Card[]>(
    Array.from({ length: cardCount }, (_, i) => ({
      id: i + 1,
      image: "/images/card_back.png",
    }))
  );
  /**
   * アニメーションが終わったら呼ばれる関数
   */
  const handleAnimationEnd = () => {
    console.log("handleAnimationEnd");
    setAnimationState("piled");
  };
  /**
   * カードがクリックされたときに呼ばれる関数
   */
  const handleCardClick = (id: number) => {
    console.log(
      `handleCardClick id: ${id} animationState: '${animationState}'`
    );
    // 'initial' なら 'shuffling' に、'piled' なら 'initial' に戻す
    animationState === "initial"
      ? setAnimationState("shuffling")
      : animationState === "piled"
      ? setAnimationState("initial")
      : undefined;
  };
  useEffect(() => {
    console.log("AnimationState changed to", animationState);
  }, [animationState]);
  return (
    <ShuffleAnimation
      cards={cards}
      onAnimationEnd={handleAnimationEnd}
      onCardClick={handleCardClick}
      animationState={animationState}
    />
  );
};
export default Component;
まとめ
Framer Motionを利用することで複雑なアニメーションを比較的簡単に実装できました!
実際のプロダクトではデザイナーさんからアニメーションの具体的な指示があるケースも多いと思いますが、Framer Motionを利用することで良い感じに実現できそうです。
今回実装したコードはGitHubに公開しています!
