CSSアニメーションを書くたびに @keyframes と transition の組み合わせで試行錯誤していた。インタラクティブなアニメーション(ホバー、ドラッグ、ページ遷移)になると特に複雑で、Reactのstate管理とも絡み合って収拾がつかなくなることがあった。
Framer Motionを使い始めてから、アニメーションの実装が劇的に楽になった。宣言的に書けて、Reactのstateと自然に連携できる。この記事では、基本的なフェードインから始めて、ページ遷移・スクロール連動アニメーションまでの実践的なパターンを紹介する。
Framer Motionの基本概念
Framer Motionの中心にあるのは motion コンポーネントだ。HTMLの任意の要素に motion. プレフィックスをつけるだけで、アニメーション対応の要素になる。
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— イージング・デュレーション・ディレイなどの設定
よく使うアニメーションプロパティ
<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で使えるほぼ全てのプロパティが使えるが、パフォーマンスのために opacity、transform(x, y, scale, rotate)を優先して使うのがいい。これらはGPUで処理されるため、レイアウトリフローが発生しない。
フェードインとスライドのパターン
最もよく使うパターンから始めよう。
ページロード時のフェードイン
"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 を段階的に増やすことで、要素が順番にフェードインするスタッガー効果が出る。
ホバーアニメーション
"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>
);
}
whileHover と whileTap を使うと、CSSの :hover や :active に相当するアニメーションを宣言的に書ける。
variantsで複雑なアニメーションを整理する
複数の要素が連携するアニメーションには variants を使う。アニメーションのステートを名前付きで定義して、親子間で共有できる。
"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 で簡単に実装できる。
"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— ビューポートの端からのオフセット。負の値だと画面に入る前にトリガーされる
// 複数の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 が必要だ。
"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に残してくれる。
トーストメッセージ
"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 フックが便利だ。
"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を優先する
// ❌ パフォーマンスが悪い(レイアウトリフローが発生)
<motion.div animate={{ width: "100%", height: 200, top: 50 }} />
// ✅ パフォーマンスが良い(GPUで処理される)
<motion.div animate={{ scaleX: 1, scaleY: 1, y: 50, opacity: 1 }} />
アクセシビリティ対応(Reduce Motion)
"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でレイアウトアニメーション
"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 |
|---|---|---|
| 基本アニメーション | フェードイン、スライド | initial、animate、transition |
| ホバー・タップ | インタラクティブなフィードバック | whileHover、whileTap |
| variants | リストのスタッガー、複雑な連携 | variants、staggerChildren |
| スクロール連動 | コンテンツの登場演出 | whileInView、viewport |
| マウント・アンマウント | モーダル、トースト | AnimatePresence、exit |
| 命令的制御 | 複雑なシーケンス | useAnimate |
| レイアウト変化 | タブ、リスト並び替え | layoutId、LayoutGroup |
アニメーションはやりすぎるとかえって体験を損なう。opacity と y の組み合わせだけで十分なことが多い。まずシンプルに始めて、必要なときだけ複雑なパターンに進むのがいい。パフォーマンスのためには transform と opacity のみを使うことを意識しておこう。