Framer Motion v12 アニメーション実装ガイド
Framer Motion v12 で UI アニメーションを組むパターン。基本の transition recipe、layoutId による magic move までを 1 ページに統合。
Framer Motion v12 で React にアニメーションを足す
Framer Motion v12 系で variants / AnimatePresence / whileHover を最小コードで使い分けるパターン、spring transition のチューニング、よく詰まる点を触れる demo で確認する実装メモ。
検証日: 2026-05-10
使用バージョン:
framer-motion@12.38.0対象: React で出現 / 切替 / hover アニメーションを宣言的に書きたい場面
Framer Motion v12 で variants / AnimatePresence / whileHover を最小コードで使い分けるパターン。spring transition の tuning、stagger / LazyMotion / useReducedMotion までを動く demo で確認します。
触って試す
variant ボタンで効果切替、show/hide で出現・退場、カードホバーで scale。
動きの “状態機械”
Framer Motion は内部で各 motion component を 3 つの アニメーション状態(initial / animate / exit)で扱います。AnimatePresence がマウント解除を遅延させるため、exit の演出が走り切ってから DOM が消えます。
ここを掴んでおくと、「exit が走らない」「hover 中に animate prop が変わったらどうなる」のような疑問は自力で追えるようになります。
最小サンプル
motion.div を <div> の代わりに使い、initial → animate の差分を Framer が自動補完:
import { motion } from "framer-motion";
export function Card() {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
Hello
</motion.div>
);
}
<motion.div /> は <div /> を完全に置き換えられる(子・class・ref 透過)。
variant パターン
繰り返し使うアニメーションは variants に切り出し:
const fadeUp = {
initial: { opacity: 0, y: 20 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -20 },
};
<motion.div variants={fadeUp} initial="initial" animate="animate" exit="exit" />
または直接展開する書き方も:
<motion.div {...fadeUp} />
退場アニメーション(AnimatePresence)
要素が消える時にアニメーションするには AnimatePresence で囲む:
import { motion, AnimatePresence } from "framer-motion";
<AnimatePresence mode="wait">
{show && (
<motion.div
key="card"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
Hello
</motion.div>
)}
</AnimatePresence>
mode の選び方
AnimatePresence の mode で「前の要素を待つか / 重ねるか / レイアウトを保つか」が変わります。
| mode | 推奨用途 |
|---|---|
sync(default) | 切り替えで重なっても問題ない時(タブ間の小ボックス等) |
wait | hero / モーダル / ページ切替。前後が重なると違和感 |
popLayout | 一覧から要素を抜く時、隣接要素を「詰めずに」演出 |
mode="wait" で前の要素退場 → 次の要素入場の順序を直列化。popLayout は要素削除時に layout prop と組み合わせる と隣接要素の自動シフトが滑らかになる。
hover / tap
whileHover / whileTap を prop に書くだけで、対応する pointer event 中だけ別 state に切り替わる:
<motion.button whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }} />
prop に書くだけで CSS transition より滑らか + transformGPU で軽い。
transition の選択
物理ベースの spring と、時間指定の duration どちらを使うかで体感が変わる:
transition={{ type: "spring", stiffness: 200, damping: 18 }}
// 物理ベース、自然な動き
transition={{ duration: 0.3, ease: "easeOut" }}
// duration ベース、コントロールしやすい
UI のフィードバック(button / card)は spring がしっくり来やすい。
spring パラメータの感覚
| param | デフォルト | 効果 | チューニングの目安 |
|---|---|---|---|
stiffness | 100 | バネの硬さ → 速さ | UI 用は 200–400(キビキビ)、装飾は 80–120(ゆったり) |
damping | 10 | 減衰 → 揺れ戻り | 過減衰なら高め(20–30)、わずかに揺れたければ 8–14 |
mass | 1 | 重さ | 重さで遅延感を出したい時のみ。通常 1 のまま |
経験則:stiffness / damping ≈ 10 前後 が 1 度小さく揺れて止まる「ちょうどいい」UI 動き。damping を stiffness に対して大きくすれば「シュッ」と止まる、小さくすれば「ぽよん」と揺れる。
stagger でリストを連鎖入場
一覧の交互入場は 親の staggerChildren + 子 variants で順序感を作ります。
- 🌱 アイテム 1
- 🪴 アイテム 2
- 🌿 アイテム 3
- 🌳 アイテム 4
- 🍀 アイテム 5
- 🌻 アイテム 6
preset で stagger 速度・方向を切替、items で要素数を変えて視覚的に確認できます。
最小コード:
const list = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { staggerChildren: 0.08, delayChildren: 0.05 },
},
};
const item = {
hidden: { opacity: 0, y: 16 },
visible: { opacity: 1, y: 0, transition: { type: "spring", stiffness: 300, damping: 24 } },
};
<motion.ul variants={list} initial="hidden" animate="visible">
{items.map((i) => (
<motion.li key={i} variants={item}>{i}</motion.li>
))}
</motion.ul>
ポイント:
- 親に variants を付けると、子も同じ variant 名(
hidden/visible)を共有して再帰的に伝播 - 子で
initial/animateを直接書く必要なし — 親の状態を継承するだけで効く staggerDirection: -1で逆順入場(末尾から)- 逆退場したい時は
exit側にも別のstaggerChildrenを入れる(demo は退場時 60% 速で stagger)
バンドル削減: LazyMotion + m コンポーネント
全部入りの motion を import すると framer-motion の機能が全部 bundle に乗って 80KB 超え。LazyMotion で機能セットを限定 + m コンポーネントで参照を短く:
import { LazyMotion, domAnimation, m } from "framer-motion";
export function App() {
return (
<LazyMotion features={domAnimation}>
{/* この内側では <m.div /> が <motion.div /> の代わり */}
<m.div animate={{ opacity: 1 }} />
</LazyMotion>
);
}
| feature pack | 含まれるもの | サイズ感(gzip) |
|---|---|---|
domAnimation | base animation, variants, exit, gesture | 約 25KB |
domMax | 上記 + drag, layout, layoutId | 約 35KB |
ルートで <LazyMotion> で囲む + 全アニメ component を m.* に置換 → サイズ 1/3 程度に。
アクセシビリティ: useReducedMotion
OS の「動きの軽減」設定を検出して、装飾アニメだけを止めるパターン。useReducedMotion() が真なら initial: false + duration: 0 で即着地:
import { useReducedMotion, motion } from "framer-motion";
export function FadeIn({ children }) {
const reduce = useReducedMotion();
return (
<motion.div
initial={reduce ? false : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={reduce ? { duration: 0 } : { duration: 0.3 }}
>
{children}
</motion.div>
);
}
OS 設定で 動きの軽減を ON にしているユーザーには:
initialをfalseにして「いきなり最終状態」で出すtransition.durationを 0 にしてジャンプさせる
「装飾アニメは reducedMotion で止める、機能上必須なフィードバック(ドラッグ追従等)は維持」が原則。
つまずいたポイント
AnimatePresenceの子にkey必須 — 同じ component でもkeyが変わらないと exit が走らない。タブ切替 / モーダル / ルート切替で頻発exitを効かせるにはAnimatePresenceで囲む —<motion.div exit={...} />単体では unmount 即時で何も起きないexitで stagger が効かない — 親 variants のtransition.staggerChildrenをexit用にも別途指定する必要がある(子が先にすべて消えた後に親が unmount するため)- state 切替時に
initialが走らない — Framer は「マウント直後だけ initial → animate 遷移」の挙動。動的に切替たい場合はkeyを付けて再 mount させる layoutprop と CSStransformの併用で意図しない動き —motion.div style={{ transform: ... }}とlayoutを同時使用すると競合。styleの transform は避けてanimate経由で- list reordering で全要素が再アニメ —
keyが安定していれば最小限の差分だけ動く。array index を key にすると順序変化で全要素が exit + initial を起こす - gestures の
whileHoverが hover 中にanimateを上書きされて止まる —whileHoverはanimateより優先度が低い。state 切替でanimateを変えると hover 効果が消えるので、hover 中は state 変更を抑制するかuseAnimate等の imperative API に切替 - bundle サイズ — 全機能で 80KB+。
mモジュール +LazyMotionで 1/3 程度に削れる(上記参照) layoutIdで magic move — 同じ id の要素が場所を変えると、Framer がレイアウトアニメーションを自動補完。一覧 → 詳細の expand 演出に強い(/articles/framer-motion-v12-layoutid-magic-move/ 参照)
評価
| 観点 | 評価 | コメント |
|---|---|---|
| 学習コスト | ◎ | <motion.div animate={...} /> だけで開始可 |
| 表現力 | ◎ | spring / stagger / layoutId / 3D まで幅広い |
| バンドル | △ | 全部入りは 80KB+、LazyMotion で削れる |
| パフォーマンス | ◎ | transformGPU、再描画最適化 |
| TypeScript | ◎ | 型推論強い、editor 補完が効く |
向く / 向かないケース
- 向く: ページ遷移、modal 出現、list の stagger 入場、UX フィードバック
- 向かない: 60fps クリティカルなゲーム / canvas アニメ(react-three-fiber + GPU)
- 向かない: SEO 用の単純な fade(CSS transition で十分、bundle 削減)
関連 Topic / 関連書籍
この記事と関係する tech-book.net の Topic と、それぞれの Topic に紐づく書籍:
React Native+Expoではじめるスマホアプリ開発 : JavaScriptによるアプリ構築の実際
「React Native」は、Facebookが開発しているスマートフォンアプリ向けの開発環境です。ほとんどのコードをJav…
Reactハンズオンラーニング 第2版 : Webアプリケーション開発のベストプラクティス
Webフロントエンドの「今」を学びたい人へ! Facebookが開発したJavaScrip…
Reactビギナーズガイド : コンポーネントベースのフロントエンド開発入門
FacebookのエンジニアによるReactの入門書! ReactによるコンポーネントベースのWebフロントエンド開発の…
TypeScriptとReact/Next.jsでつくる実践Webアプリケーション開発
新しいフロントエンドの入門書決定版! 本書はReact/Next.jsとTypeScriptを用いてWebアプリケーションを開…
【POD】React & Gatsby開発入門
Framer Motion v12 の layoutId で magic move + LazyMotion で削る
Framer Motion v12 の layoutId で grid → 詳細展開を魔法のように繋ぐ実装、LayoutGroup の使いどころ、LazyMotion + domAnimation で bundle を 1/3 に減らす Tree-shake パターンを触れる demo で確認する実装メモ。
検証日: 2026-05-10
使用バージョン:
framer-motion@12.38.0対象: 入門の
<motion.div animate={...} />は書けた、shared element transition と bundle 削減を覚えたい人
同じ layoutId を持つ別要素間で「グリッド → 詳細展開」を魔法のように繋ぐ magic move と、LazyMotion + domAnimation で bundle を 1/3 に減らす tree-shake パターンを動く demo で確認します。
触って試す
カードをクリックすると詳細パネルへと同じ要素が移動して拡大するように見える。実装は 同じ layoutId を持つ別の <motion.div> を出すだけ。
1. layoutId とは
layoutId="card-1" を 2 つの <motion.div> に同時に付け、片方が unmount された瞬間にもう片方を mount すると、Framer Motion が 位置・サイズ・形 をスムーズに繋いでくれる。
// Step 1: グリッド側
<motion.div layoutId="card-1" onClick={() => setOpen("1")}>
<motion.h3 layoutId="title-1">記事タイトル</motion.h3>
</motion.div>
// Step 2: 開いた時(別の場所に同じ layoutId)
{open === "1" && (
<motion.div layoutId="card-1" className="...detail-position">
<motion.h3 layoutId="title-1" className="...big">記事タイトル</motion.h3>
<p>詳細本文</p>
</motion.div>
)}
ポイント:
- 片方 unmount + もう片方 mount で transition が走る
- 入れ子の
<motion.h3 layoutId>も同じ id ペアで個別に補完される AnimatePresenceでラップしないと unmount アニメーションが効かない
2. AnimatePresence + layoutId の組み合わせ
<LayoutGroup> で全体を囲み、grid 側 / 詳細側に 同じ layoutId を持つ要素を出し分ける。<AnimatePresence> で unmount アニメも有効化:
import { motion, AnimatePresence, LayoutGroup } from "framer-motion";
<LayoutGroup>
<div className="grid grid-cols-4 gap-4">
{cards.map((c) => (
<motion.button
key={c.id}
layoutId={`card-${c.id}`}
onClick={() => setOpen(c.id)}
>
<motion.h3 layoutId={`title-${c.id}`}>{c.title}</motion.h3>
</motion.button>
))}
</div>
<AnimatePresence>
{opened && (
<motion.div
layoutId={`card-${opened}`}
className="modal-position"
>
<motion.h3 layoutId={`title-${opened}`} className="big">
{opened.title}
</motion.h3>
<motion.p initial={{ opacity: 0 }} animate={{ opacity: 1, transition: { delay: 0.15 } }}>
{opened.body}
</motion.p>
</motion.div>
)}
</AnimatePresence>
</LayoutGroup>
ポイント:
<LayoutGroup>で囲う:複数の layoutId を同期させて滑らかに繋ぐ- 詳細側の追加要素(本文
p等)はdelayを入れて magic move の後にフェードイン exitで逆再生:AnimatePresence内の要素は exit で grid 位置に戻る
3. magic move が破綻するケース
| 症状 | 原因 |
|---|---|
| 飛ばない | 同じ layoutId のペアが見つからない(typo / map の key 漏れ) |
| 跳ねる | 親の transform / scroll の中で計算が壊れる(scrollY を考慮しない) |
| 詰まる | 大量カードを LayoutGroup で束ねすぎ(数百以上だと measure コスト) |
| ガクつく | unmount → mount の間に gap があると一瞬消える(重複 mount を避ける) |
実装は ID の整合性が 9 割。
4. LazyMotion で bundle を削る
framer-motion 全部入りは min+gzip で 80KB+。実際に使う機能だけにすると 1/3 程度に。
import { LazyMotion, domAnimation, m } from "framer-motion";
// motion.div の代わりに m.div
<LazyMotion features={domAnimation}>
<m.div animate={{ opacity: 1 }} />
</LazyMotion>
ポイント:
mから import(motionだと全機能が tree-shake されない)features={domAnimation}= 標準アニメーション一式(animate / variants / transition)features={domMax}= + drag / layout(layoutId は domMax 必要)- import を 1 箇所にまとめて wrapper 化:プロジェクト全体で
mのみ使うように規約化
domMax 使う時のサイズ感:
| 構成 | 概算サイズ(min+gzip) |
|---|---|
motion 全部入り | ~80 KB |
LazyMotion + domAnimation | ~25 KB |
LazyMotion + domMax | ~50 KB |
layoutId / drag を使うと domMax なので 50KB ほどに。framer-motion 比で 30% 削減。
5. spring 物理 vs duration の使い分け
UI フィードバックは spring(自然な減衰)、一覧の入場は duration(順序感)、magic move は spring 推奨(物理的に繋がって見える):
// UI フィードバック(button / card / modal)→ spring 推奨
transition={{ type: "spring", stiffness: 200, damping: 18 }}
// 一覧の stagger 入場 → duration
transition={{ duration: 0.3, ease: "easeOut" }}
// magic move → デフォルトの spring が自然
// (transition を省略すると自動で spring)
ポイント:
- spring の
stiffness高い = 速く減衰、damping高い = オーバーシュート少ない - 推奨初期値:
stiffness: 200, damping: 18(Apple っぽい質感) - duration 派は
ease: "easeOut"で「最後にゆっくり」が UX 自然
6. reducedMotion(アクセシビリティ)
useReducedMotion() で OS 設定を検出し、true なら transition の duration を 0 にして即着地させる:
import { useReducedMotion } from "framer-motion";
function Card() {
const reduced = useReducedMotion();
return (
<motion.div
animate={{ opacity: 1 }}
transition={reduced ? { duration: 0 } : { type: "spring" }}
/>
);
}
OS 設定で reduced motion を有効にしているユーザは、アニメーションを完全に止める のが UX 配慮。
つまずいたポイント
- 同じ
layoutIdが同時に 2 つ mount している:両方が同時に存在する瞬間があると、Framer Motion が「どっちに繋ぐか」迷う。AnimatePresence mode="wait"を使うか、unmount を完全にしてから mount whileHover={{ scale }}とlayoutの競合:layout 中は scale が無視されることがある、whileHoverの代わりにtransition内の whileHover state を別管理- scroll 中の magic move 計算ずれ:
<LayoutGroup>の親が scroll コンテナだと位置計算がずれる、<LayoutGroup id="stable-id">で安定 group key を持たせる domAnimationで layoutId が動かない:layoutId は drag / layout features の一部、domMaxを選ぶm.divとmotion.divの混在:mを使うなら全部m、混ぜると LazyMotion 効果が減る
関連 Topic / 関連書籍
この記事と関係する tech-book.net の Topic と、それぞれの Topic に紐づく書籍:
React Native+Expoではじめるスマホアプリ開発 : JavaScriptによるアプリ構築の実際
「React Native」は、Facebookが開発しているスマートフォンアプリ向けの開発環境です。ほとんどのコードをJav…
Reactハンズオンラーニング 第2版 : Webアプリケーション開発のベストプラクティス
Webフロントエンドの「今」を学びたい人へ! Facebookが開発したJavaScrip…
Reactビギナーズガイド : コンポーネントベースのフロントエンド開発入門
FacebookのエンジニアによるReactの入門書! ReactによるコンポーネントベースのWebフロントエンド開発の…
TypeScriptとReact/Next.jsでつくる実践Webアプリケーション開発
新しいフロントエンドの入門書決定版! 本書はReact/Next.jsとTypeScriptを用いてWebアプリケーションを開…
【POD】React & Gatsby開発入門
次に読むガイド
- 2 セクション統合
React Virtuoso 仮想スクロール実装ガイド
React Virtuoso で大量データの仮想スクロールを組む実装ノート。基本の virtual list、グループ化 + sticky header の応用までを 1 ページに統合。
- 2 セクション統合
dnd-kit v6 ドラッグ & ドロップ実装ガイド
dnd-kit v6 でドラッグ&ドロップ UI を組む実装パターン。sortable list の最小実装、複数カラムを跨ぐ Kanban(列移動の状態管理を含む)、useSensor / DragOverlay の細部までを 1 ページに統合した実装ノート。