logo

framer-motionで数字の変更にアニメーションを加える

2024-08-09
4 months ago

開発環境

  • next 14.2.5
  • framer-motion 11.3.24

前提

ライブラリを利用することでお手軽にアニメーションをつけて、UIをリッチにしたい方を対象にしています。

また、本記事で利用しているframer-motionuseAnimate Hookに関しての説明はほぼ省いているので詳しくはドキュメントを参照してください。

本題

今回目指す完成品はよくSNSであるいいね!などの横にある数字のアニメーションです。

数字が増えた時は、上にヒュッと消え、下からヒュッと出てくる、あれです。

今回は数字が増えた時と減った時、両方のアニメーションを実現できるように実装してみます。

まず、下準備として親コンポーネントを用意し、ここで数字を管理し、カウントアップ、カウントダウンするようにします。

'use client';

import { useState } from 'react';

import { Child } from './child';

export const Parent = () => {
  const [count, setCount] = useState(0);

  return (
    <div>
      <div className="flex gap-4">
        <button
          onClick={() => setCount((prev) => prev + 1)}
          className="rounded-lg border px-1 hover:opacity-70"
        >
          CountUp
        </button>
        <button
          onClick={() => setCount((prev) => prev - 1)}
          className="rounded-lg border px-1 hover:opacity-70"
        >
          CountDown
        </button>
      </div>
      <Child count={count} />
    </div>
  );
};
Parent.tsx

そして、親コンポーネントで子コンポーネントを呼び出します。

この子コンポーネントが今回の本題です。

import { useEffect, useState } from 'react';

import { useAnimate } from 'framer-motion';

export const Child = ({ count }: { count: number }) => {
  const [scope, animate] = useAnimate();
  const [prevCount, setPrevCount] = useState(count);

  useEffect(() => {
    const runAnimation = async () => {
      if (prevCount !== count) {
        const direction = count > prevCount ? 1 : -1;
        // 1. 現在の数字を上/下に動かし、透明にする
        await animate(
          scope.current,
          { y: [0, direction * -10], opacity: [1, 0] },
          { duration: 0.1 },
        );
        // 2. 数字を更新
        setPrevCount(count);
        // 3. 新しい数字を下/上から動かしながら表示
        await animate(
          scope.current,
          { y: [direction * 10, 0], opacity: [0, 1] },
          { duration: 0.1 },
        );
      }
    };

    runAnimation();
  }, [count, prevCount, animate, scope]);

  return (
    <div className="pt-8 text-3xl">
      <div ref={scope}>{prevCount}</div>
    </div>
  );
};
Child.tsx

親コンポーネントからpropscount(数字)を受け取ります。

この親から受け取ったcountを利用して元々表示していた数字が増えた・減ったによってアニメーションを変えるようにしています。

ポイント① アニメーションを連続して実行できる

async/awaitで記述している通り、今回のようなケースでは

  1. 現在の数字を上/下に動かし、透明にする
  2. 数字を更新
  3. 新しい数字を下/上から動かしながら表示

のように、連続して動きを実装する必要があります。

useAnimateでは、これを簡単に実装できます。

ポイント② prevCountを表示して自然なアニメーションを作る

何度も記載しますが、今回は以下の一連の流れがあります。

  1. 現在の数字を上/下に動かし、透明にする
  2. 数字を更新
  3. 新しい数字を下/上から動かしながら表示

この「2. 数字を更新」とprevCountを表示に利用しなければならない理由があります。

具体例を出します。

仮に、元々の数字が「3」で新しく「4」にcountが変わったとします。

この時、表示にcountを利用していたら、「1. 現在の数字を上/下に動かし、透明にする」のフェーズの時点で数字が「4」に変わってしまい、動き的には「3から4」から「4」に見えます。

  return (
    <div className="pt-8 text-3xl">
      <div ref={scope}>{count}</div>
    </div>
  );

これを回避するために、表示用に状態を管理(今回はprevCountという名前で)して、「1. 現在の数字を上/下に動かし、透明にする」のフェーズでは数字が変わっていないようにしています。

これで、動き的には「3」から「4」に見えます。

さいごに

もっとシンプルに、できるだけuseEffect, useStateを利用しない実装ができれば良いのですが、今回紹介した実装で自然な動きは実現できると思います。

また、framer-motion以外にもreact-springなど他にもアニメーション系のライブラリはあるのでいろいろ試してみてください。

参照