はじめに
この記事は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に公開しています!