32blogby StudioMitsu
react12 min read

Framer Motionで作るReactアニメーション

Framer Motionの基本から実践まで解説。フェードイン・スライド・ページ遷移・スクロール連動アニメーションを実例コード付きで紹介。

reactframer-motionアニメーションCSSUX
目次

CSSアニメーションを書くたびに @keyframestransition の組み合わせで試行錯誤していた。インタラクティブなアニメーション(ホバー、ドラッグ、ページ遷移)になると特に複雑で、Reactのstate管理とも絡み合って収拾がつかなくなることがあった。

Framer Motionを使い始めてから、アニメーションの実装が劇的に楽になった。宣言的に書けて、Reactのstateと自然に連携できる。この記事では、基本的なフェードインから始めて、ページ遷移・スクロール連動アニメーションまでの実践的なパターンを紹介する。

Framer Motionの基本概念

Framer Motionの中心にあるのは motion コンポーネントだ。HTMLの任意の要素に motion. プレフィックスをつけるだけで、アニメーション対応の要素になる。

tsx
import { motion } from "framer-motion";

// motion.div は通常の div + アニメーション能力
function FadeInBox() {
  return (
    <motion.div
      initial={{ opacity: 0 }}    // 初期状態
      animate={{ opacity: 1 }}    // 目標状態
      transition={{ duration: 0.5 }} // アニメーションの設定
    >
      フェードインするボックス
    </motion.div>
  );
}

3つのpropsを覚えるだけで基本的なアニメーションが書ける:

  • initial — アニメーション開始前の状態(初期値)
  • animate — アニメーション後の目標状態
  • transition — イージング・デュレーション・ディレイなどの設定

よく使うアニメーションプロパティ

tsx
<motion.div
  initial={{ opacity: 0, y: 20, scale: 0.95 }}
  animate={{ opacity: 1, y: 0, scale: 1 }}
  transition={{
    duration: 0.4,          // 秒単位
    ease: "easeOut",        // イージング
    delay: 0.1,             // 遅延(秒)
  }}
>
  コンテンツ
</motion.div>

CSSで使えるほぼ全てのプロパティが使えるが、パフォーマンスのために opacitytransform(x, y, scale, rotate)を優先して使うのがいい。これらはGPUで処理されるため、レイアウトリフローが発生しない。

フェードインとスライドのパターン

最もよく使うパターンから始めよう。

ページロード時のフェードイン

tsx
"use client";
import { motion } from "framer-motion";

function HeroSection() {
  return (
    <section>
      <motion.h1
        initial={{ opacity: 0, y: 30 }}
        animate={{ opacity: 1, y: 0 }}
        transition={{ duration: 0.6, ease: "easeOut" }}
      >
        ページタイトル
      </motion.h1>

      <motion.p
        initial={{ opacity: 0, y: 20 }}
        animate={{ opacity: 1, y: 0 }}
        transition={{ duration: 0.6, ease: "easeOut", delay: 0.15 }}
      >
        サブテキスト
      </motion.p>

      <motion.button
        initial={{ opacity: 0, y: 15 }}
        animate={{ opacity: 1, y: 0 }}
        transition={{ duration: 0.5, ease: "easeOut", delay: 0.3 }}
      >
        はじめる
      </motion.button>
    </section>
  );
}

delay を段階的に増やすことで、要素が順番にフェードインするスタッガー効果が出る。

ホバーアニメーション

tsx
"use client";
import { motion } from "framer-motion";

function AnimatedCard({ title, description }: { title: string; description: string }) {
  return (
    <motion.div
      className="rounded-lg border p-6 cursor-pointer"
      whileHover={{
        scale: 1.02,
        boxShadow: "0 10px 40px rgba(0,0,0,0.15)",
      }}
      whileTap={{ scale: 0.98 }}
      transition={{ type: "spring", stiffness: 400, damping: 30 }}
    >
      <h3>{title}</h3>
      <p>{description}</p>
    </motion.div>
  );
}

whileHoverwhileTap を使うと、CSSの :hover:active に相当するアニメーションを宣言的に書ける。

variantsで複雑なアニメーションを整理する

複数の要素が連携するアニメーションには variants を使う。アニメーションのステートを名前付きで定義して、親子間で共有できる。

tsx
"use client";
import { motion } from "framer-motion";

// アニメーションステートを名前付きで定義
const containerVariants = {
  hidden: { opacity: 0 },
  visible: {
    opacity: 1,
    transition: {
      staggerChildren: 0.1, // 子要素を0.1秒ずつずらして表示
      delayChildren: 0.2,   // 子要素の開始を0.2秒遅らせる
    },
  },
};

const itemVariants = {
  hidden: { opacity: 0, y: 20 },
  visible: {
    opacity: 1,
    y: 0,
    transition: { duration: 0.4, ease: "easeOut" },
  },
};

function AnimatedList({ items }: { items: string[] }) {
  return (
    <motion.ul
      variants={containerVariants}
      initial="hidden"
      animate="visible"
    >
      {items.map((item, i) => (
        <motion.li key={i} variants={itemVariants}>
          {item}
        </motion.li>
      ))}
    </motion.ul>
  );
}

staggerChildren がポイントだ。親の visible アニメーション時に、子要素を指定した間隔でずらして表示する。各 motion.li に同じ variants を渡すだけで、タイミングは自動的に管理される。

スクロール連動アニメーション(whileInView)

スクロールしながら要素が現れるアニメーションは、whileInView で簡単に実装できる。

tsx
"use client";
import { motion } from "framer-motion";

function FeatureCard({ icon, title, description }: FeatureCardProps) {
  return (
    <motion.div
      className="rounded-xl p-8 border"
      initial={{ opacity: 0, y: 40 }}
      whileInView={{ opacity: 1, y: 0 }}
      viewport={{ once: true, margin: "-50px" }} // 画面下50pxの位置でトリガー
      transition={{ duration: 0.5, ease: "easeOut" }}
    >
      <div className="text-4xl mb-4">{icon}</div>
      <h3 className="text-xl font-bold mb-2">{title}</h3>
      <p>{description}</p>
    </motion.div>
  );
}

viewport の設定:

  • once: true — 一度表示されたら再度アニメーションしない(推奨)
  • margin — ビューポートの端からのオフセット。負の値だと画面に入る前にトリガーされる
tsx
// 複数のFeatureCardをスタッガーで表示する場合
function FeaturesSection() {
  const features = [
    { icon: "⚡", title: "高速", description: "..." },
    { icon: "🔒", title: "安全", description: "..." },
    { icon: "🎯", title: "正確", description: "..." },
  ];

  return (
    <div className="grid grid-cols-3 gap-6">
      {features.map((feature, i) => (
        <FeatureCard
          key={i}
          {...feature}
          // 遅延でスタッガー効果
          // FeatureCard の transition に delay を追加する場合は propsとして渡す
        />
      ))}
    </div>
  );
}

AnimatePresenceでマウント・アンマウントをアニメーションする

コンポーネントが表示・非表示になるときのアニメーションには AnimatePresence が必要だ。

tsx
"use client";
import { motion, AnimatePresence } from "framer-motion";
import { useState } from "react";

function Modal({ isOpen, onClose, children }: ModalProps) {
  return (
    <AnimatePresence>
      {isOpen && (
        <>
          {/* 背景オーバーレイ */}
          <motion.div
            key="overlay"
            className="fixed inset-0 bg-black/50"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            onClick={onClose}
          />

          {/* モーダル本体 */}
          <motion.div
            key="modal"
            className="fixed inset-x-4 top-1/2 -translate-y-1/2 max-w-lg mx-auto
                       bg-white rounded-2xl p-8 shadow-2xl z-10"
            initial={{ opacity: 0, scale: 0.95, y: 20 }}
            animate={{ opacity: 1, scale: 1, y: 0 }}
            exit={{ opacity: 0, scale: 0.95, y: 20 }}
            transition={{ type: "spring", stiffness: 350, damping: 30 }}
          >
            {children}
            <button onClick={onClose}>閉じる</button>
          </motion.div>
        </>
      )}
    </AnimatePresence>
  );
}

exit プロパティが AnimatePresence の核心だ。通常のReactではDOMからすぐ削除されるが、AnimatePresence はexit アニメーションが完了するまでDOMに残してくれる。

トーストメッセージ

tsx
"use client";
import { motion, AnimatePresence } from "framer-motion";
import { useState } from "react";

type Toast = { id: number; message: string };

function ToastContainer() {
  const [toasts, setToasts] = useState<Toast[]>([]);

  const addToast = (message: string) => {
    const id = Date.now();
    setToasts(prev => [...prev, { id, message }]);
    setTimeout(() => {
      setToasts(prev => prev.filter(t => t.id !== id));
    }, 3000);
  };

  return (
    <>
      <button onClick={() => addToast("保存しました!")}>保存</button>

      <div className="fixed bottom-4 right-4 flex flex-col gap-2">
        <AnimatePresence>
          {toasts.map(toast => (
            <motion.div
              key={toast.id}
              className="bg-gray-900 text-white px-4 py-3 rounded-lg shadow-lg"
              initial={{ opacity: 0, x: 50, scale: 0.95 }}
              animate={{ opacity: 1, x: 0, scale: 1 }}
              exit={{ opacity: 0, x: 50, scale: 0.95 }}
              transition={{ duration: 0.2 }}
            >
              {toast.message}
            </motion.div>
          ))}
        </AnimatePresence>
      </div>
    </>
  );
}

useAnimateで命令的にアニメーションを制御する

複雑なシーケンシャルアニメーションや、特定のタイミングで実行するアニメーションには useAnimate フックが便利だ。

tsx
"use client";
import { useAnimate } from "framer-motion";

function SubmitButton() {
  const [scope, animate] = useAnimate();

  const handleSubmit = async () => {
    // 1. ボタンを押したときのフィードバック
    await animate(scope.current, { scale: 0.95 }, { duration: 0.1 });
    await animate(scope.current, { scale: 1 }, { duration: 0.1 });

    // 2. ローディング中のアニメーション(疑似コード)
    animate(scope.current, { opacity: 0.7 });

    try {
      await submitForm();

      // 3. 成功時のアニメーション
      await animate(scope.current, { backgroundColor: "#22c55e" }, { duration: 0.3 });
      await animate(scope.current, { backgroundColor: "#3b82f6" }, { duration: 0.5 });
    } catch {
      // 4. エラー時のシェイクアニメーション
      await animate(scope.current, { x: [-8, 8, -8, 8, 0] }, { duration: 0.4 });
    }
  };

  return (
    <button ref={scope} onClick={handleSubmit} className="px-6 py-3 bg-blue-500 rounded-lg">
      送信
    </button>
  );
}

animate 関数はPromiseを返すので、await でアニメーションの完了を待ってから次のアニメーションを実行できる。

パフォーマンスのベストプラクティス

transformとopacityを優先する

tsx
// ❌ パフォーマンスが悪い(レイアウトリフローが発生)
<motion.div animate={{ width: "100%", height: 200, top: 50 }} />

// ✅ パフォーマンスが良い(GPUで処理される)
<motion.div animate={{ scaleX: 1, scaleY: 1, y: 50, opacity: 1 }} />

アクセシビリティ対応(Reduce Motion)

tsx
"use client";
import { motion, useReducedMotion } from "framer-motion";

function AnimatedCard({ children }: { children: React.ReactNode }) {
  const shouldReduceMotion = useReducedMotion();

  return (
    <motion.div
      initial={{ opacity: 0, y: shouldReduceMotion ? 0 : 20 }}
      animate={{ opacity: 1, y: 0 }}
      transition={{ duration: shouldReduceMotion ? 0 : 0.4 }}
    >
      {children}
    </motion.div>
  );
}

useReducedMotion はユーザーのOS設定(「視差効果を減らす」等)を検出する。本番アプリでは必ず考慮しておきたい。

layoutIdでレイアウトアニメーション

tsx
"use client";
import { motion, LayoutGroup } from "framer-motion";
import { useState } from "react";

function TabNav() {
  const [activeTab, setActiveTab] = useState("home");
  const tabs = ["home", "about", "contact"];

  return (
    <LayoutGroup>
      <nav className="flex gap-4 relative">
        {tabs.map(tab => (
          <button
            key={tab}
            onClick={() => setActiveTab(tab)}
            className="relative px-4 py-2"
          >
            {tab}
            {activeTab === tab && (
              // layoutId が同じ要素間でスムーズにアニメーションする
              <motion.div
                layoutId="active-indicator"
                className="absolute inset-0 bg-blue-100 rounded-md -z-10"
              />
            )}
          </button>
        ))}
      </nav>
    </LayoutGroup>
  );
}

layoutId を使うと、同じIDを持つ要素間でのレイアウト変化をFramer Motionが自動的にアニメーション補完してくれる。タブのアンダーラインやカードの展開など、要素の「位置が変わる」アニメーションに最適だ。

まとめ

Framer Motionのコアパターンをまとめると:

パターン使いどころ主なprops
基本アニメーションフェードイン、スライドinitialanimatetransition
ホバー・タップインタラクティブなフィードバックwhileHoverwhileTap
variantsリストのスタッガー、複雑な連携variantsstaggerChildren
スクロール連動コンテンツの登場演出whileInViewviewport
マウント・アンマウントモーダル、トーストAnimatePresenceexit
命令的制御複雑なシーケンスuseAnimate
レイアウト変化タブ、リスト並び替えlayoutIdLayoutGroup

アニメーションはやりすぎるとかえって体験を損なう。opacityy の組み合わせだけで十分なことが多い。まずシンプルに始めて、必要なときだけ複雑なパターンに進むのがいい。パフォーマンスのためには transformopacity のみを使うことを意識しておこう。