😃

requestAnimationFrameを使ってアニメーションを作ってみる

2025/04/10に公開

ちょっと株式会社でフロントエンドエンジニアをしているでんです。
業務の中でrequestAnimationFrameに触れる機会がありましたので紹介できればと思います。

requestAnimationFrameとは

window.requestAnimationFrame() メソッドは、ブラウザーにアニメーションを行いたいことを知らせ、指定した関数を呼び出して次の再描画の前にアニメーションを更新することを要求します。このメソッドは、再描画の前に呼び出されるコールバック 1 個を引数として取ります。
https://developer.mozilla.org/ja/docs/Web/API/Window/requestAnimationFrame

MDNにある通り、動きのあるものを作りたい時に使えそうですね。
アニメーションのように連続して動かしたい場合は、requestAnimationFrame(callback)のように引数にcallback関数を渡します。

requestAnimationFrameの特徴として、1フレームに対しての処理という点です。リフレッシュレートが60FPSであれば1秒間に60回requestAnimationFrameに渡したコールバック関数が実行されるということになります。

実装例

コード
import { useEffect, useRef, useState } from "react";

type EmojiProps = {
  fontSize: number;
};

const CryEmoji = ({ fontSize }: EmojiProps) => {
  return <span className={`text-[${fontSize}px]`}>😢</span>;
};

const SmileEmoji = ({ fontSize }: EmojiProps) => {
  return <span className={`text-[${fontSize}px]`}>😃</span>;
};

const FrownEmoji = ({ fontSize }: EmojiProps) => {
  return <span className={`text-[${fontSize}px]`}>🙁</span>;
};

const EMOJI_WIDTH = 75;
const TOTAL_EMOJI_WIDTH = EMOJI_WIDTH * 4;
const ROTATION_SPEED = 1;
const STOP_SPEED = ROTATION_SPEED / 15;

const App = () => {
  const emojiContentRef = useRef<HTMLDivElement>(null);
  const [isRunning, setIsRunning] = useState(false);
  const positionX = useRef(0);
  const targetStopPosition = useRef<number | null>(null);

  const handleRotation = (deltaTime: number) => {
    const movement = ROTATION_SPEED * deltaTime;
    positionX.current -= movement;
    if (positionX.current < -TOTAL_EMOJI_WIDTH + EMOJI_WIDTH) {
      positionX.current = 0;
    }
    return true;
  };

  const handleStop = (deltaTime: number, emojiContent: HTMLDivElement) => {
    const currentPosition = Math.abs(positionX.current);
    const targetPosition = targetStopPosition.current!;
    const distance = targetPosition - currentPosition;

    // 目標位置に十分近づいたら完全停止
    if (Math.abs(distance) < 1) {
      positionX.current = -targetPosition;
      emojiContent.style.transform = `translateX(${positionX.current}px)`;
      targetStopPosition.current = null;
      return false;
    }

    const movement = STOP_SPEED * deltaTime;
    positionX.current -= movement;
    return true;
  };

  const toggleAnimation = () => {
    if (isRunning) {
      const currentPosition = Math.abs(positionX.current);
      const nextEmojiPosition =
        Math.ceil(currentPosition / EMOJI_WIDTH) * EMOJI_WIDTH;
      targetStopPosition.current = nextEmojiPosition;
    } else {
      targetStopPosition.current = null;
    }
    setIsRunning(!isRunning);
  };

  useEffect(() => {
    let animationFrameId = 0;
    let previousTimestamp: number | null = null;

    const animate = (timestamp: number) => {
      const emojiContent = emojiContentRef.current;
      if (!emojiContent) return;

      if (!previousTimestamp) {
        previousTimestamp = timestamp;
      }

      const deltaTime = timestamp - previousTimestamp;
      previousTimestamp = timestamp;

      let shouldContinue = false;

      if (!isRunning && targetStopPosition.current !== null) {
        shouldContinue = handleStop(deltaTime, emojiContent);
      } else if (isRunning) {
        shouldContinue = handleRotation(deltaTime);
      }

      emojiContent.style.transform = `translateX(${positionX.current}px)`;

      if (shouldContinue) {
        animationFrameId = requestAnimationFrame(animate);
      }
    };

    if (isRunning || targetStopPosition.current !== null) {
      animationFrameId = requestAnimationFrame(animate);
    }

    return () => cancelAnimationFrame(animationFrameId);
  }, [isRunning]);

  return (
    <main className="max-w-[640px] mx-auto my-10 px-5">
      <div className="flex justify-center">
        <div className="border border-black w-[75px] overflow-hidden">
          <div
            ref={emojiContentRef}
            className="grid grid-cols-4 text-center w-[300px] relative leading-none"
          >
            <SmileEmoji fontSize={75} />
            <CryEmoji fontSize={75} />
            <FrownEmoji fontSize={75} />
            <SmileEmoji fontSize={75} />
          </div>
        </div>
      </div>
      <div className="flex justify-center mt-4">
        <button
          className="bg-blue-500 text-white px-4 py-2 rounded"
          onClick={toggleAnimation}
        >
          {isRunning ? "アニメーションを停止" : "アニメーションをスタート"}
        </button>
      </div>
    </main>
  );
};

export default App;

部分的に解説

if (isRunning || targetStopPosition.current !== null) {
  animationFrameId = requestAnimationFrame(animate);
}

アニメーションを担うanimateをrequestAnimationFrameに渡すことで動きます。
次のフレームも動かしたい条件の時にanimateの中でrequestAnimationFrame(animate)を呼ぶと連続して動かし続けることができます。

const handleRotation = (deltaTime: number) => {
  const movement = ROTATION_SPEED * deltaTime;
  positionX.current -= movement;
  if (positionX.current < -TOTAL_EMOJI_WIDTH + EMOJI_WIDTH) {
    positionX.current = 0;
  }
  return true;
};

handleRotationが1フレームあたりどれくらい動かすかを求めています。
positionX.current -= 10;のように固定値を入れると、リフレッシュレートによって速度が変わります。
リフレッシュレートに左右されずに速度を保ちたい場合はdeltaTimeのように前回のフレームから今回のフレームまでの経過時間を計算して使うことになるかと思います。

return () => cancelAnimationFrame(animationFrameId);

最後にクリーンアップ処理にcancelAnimationFrameを使ってスケジュールされたアニメーションフレームリクエストをキャンセルしておきましょう。

まとめ

requestAnimationFrameを使ったアニメーションを紹介させていただきました。
再生、停止の他、停止後に別のアニメーションを追加してより楽しいアニメーション作りができるといいですね!

chot Inc. tech blog

Discussion