Framer Motionの退場・レイアウトアニメ実装:CSSで詰む動きをReactで足す
リストの並び替えやモーダルの退場はCSSだと詰む。Framer Motion(motion)のAnimatePresenceとlayout、Web Animations APIの使い分けを、コピペで動くReactコードで解説。
「このモーダル、閉じるときもふわっと消して」
Claude Codeにそう頼んだら、出てきたコードは opacity: 0 のCSSトランジションでした。開くときはきれいにフェードイン。でも閉じるボタンを押すと、モーダルは一瞬で消えました。フェードアウトしない。
理由はあとで腑に落ちました。Reactで setShow(false) した瞬間、要素はDOMから消えます。消えた要素にトランジションをかけても、もう描画する箱がない。CSSの transition は「存在し続ける要素のプロパティが変わったとき」しか動かないので、退場の演出だけは原理的に届かないんです。
ここがCSSの限界線です。入場のフェードインや、ホバーで色が変わる程度なら、CSSのほうが軽くて確実。でも「消える瞬間を見せる」「リストを並び替えたときに各カードがスーッと移動する」「要素のサイズが変わるのを滑らかに繋ぐ」——このあたりはJavaScript、というかライブラリの出番です。
この記事は、そのJS駆動・ライブラリ側に振り切って書きます。主役はFramer Motion(現在のパッケージ名は motion)。標準のWeb Animations APIも併せて、いつ素のCSSで足りて、いつライブラリを入れるべきかの線引きまでやります。純粋なCSSアニメーション(@keyframes・transition・will-change)を深掘りしたい人は、姉妹記事のCSSアニメーションが重い・酔うを直すへどうぞ。こっちはあくまで「CSSで詰んだ動き」を担当します。
この記事の要点
- Reactで要素が消える瞬間を演出したいなら、CSSのトランジションでは届かない。
AnimatePresenceで囲ってexitを書く。 - リストの並び替え・追加削除で各要素を滑らかに動かすなら
layoutプロップ。位置の差分はライブラリが勝手に計算してくれる。 - ライブラリは無料ではない。バンドルが数十KB増える。フェードイン・ホバー・スクロール表示はCSSで十分で、入れる前に「CSSで無理か?」を一度疑う。
- 1回きりの単発演出(クリックでボタンが軽く弾む等)は、依存ゼロのWeb Animations API (
element.animate()) が軽くて速い。 - Claude Codeに任せるときは「
motionパッケージで」「prefers-reduced-motionを尊重して」まで指示しないと、退場アニメが消えたり古いAPIで書かれたりする。
まず「CSSで足りるか」を疑う
ライブラリを入れる前に、これだけは毎回自分に問います。「この動き、CSSで無理?」と。多くの動きは、答えが「いける」です。
下の表が、僕の中の線引きです。
| やりたいこと | CSSで足りる? | 道具 |
|---|---|---|
| ホバーで色・影が変わる | 足りる | transition |
| スクロールで要素がフェードイン | 足りる | transition + Intersection Observer |
| ローディングのスピナー回転 | 足りる | @keyframes |
| クリックでボタンが一度だけ弾む | ほぼ足りる | Web Animations API か CSS |
| 要素が消える瞬間を見せる(React) | 足りない | Framer Motion AnimatePresence |
| リスト並び替えで各要素が移動 | 足りない | Framer Motion layout |
| ドラッグ、慣性、ジェスチャー | 足りない | Framer Motion / 専用ライブラリ |
| 複数要素を時間差で連鎖 | 厳しい | Framer Motion の stagger |
上3つは、はっきり言ってライブラリを入れたら負けです。framer-motion を npm install した瞬間に数十KBのバンドルが乗るので、スピナー1個のためにそれを払うのは割に合いません。スクロール表示も、Intersection Observerと数行のCSSで終わります(これは姉妹記事で詳しくやっています)。
ライブラリが本当に要るのは、表の下半分。DOMから消える要素と、位置が変わる要素。この2つはCSSの設計思想の外側にあるので、素直に道具を借りたほうが速いし安全です。
Framer Motion(motion)を入れる
パッケージ名が一度変わっているので、ここだけ注意してください。昔は framer-motion でしたが、今は motion です(公式ドキュメントも npm install motion を案内しています)。importも motion/react に変わっています。Claude Codeや古い記事は今でも framer-motion で書いてくることがあるので、生成コードを見たら最初にここをチェックします。
# 新しいパッケージ名はこちら
npm install motion
# 古い記事だと framer-motion を案内されるが、今はこっち
importはこう書きます。
import { motion, AnimatePresence } from "motion/react";
motion.div や motion.button のように、HTMLタグの前に motion. を付けるだけで、その要素がアニメーション可能になります。あとは animate や exit といったプロップで「どう動くか」を宣言する。命令的に「今動け」と書くのではなく、「この状態になる」と宣言するのがこのライブラリの肝です。
退場アニメ:消える瞬間を AnimatePresence で掴む
冒頭で詰まったやつです。Reactで要素を条件付きレンダリングすると、false になった瞬間にDOMから消える。だから退場演出が効かない。
AnimatePresence は、この「消える直前」を一瞬だけ掴んでくれる仕組みです。子要素が消えそうになると、退場アニメ(exit)が終わるまでDOMからの削除を待ってくれます。コピペで動く完成形がこちらです。
import { useState } from "react";
import { motion, AnimatePresence } from "motion/react";
export function Modal() {
const [open, setOpen] = useState(false);
return (
<div>
<button onClick={() => setOpen(true)}>開く</button>
{/* AnimatePresence で囲むと、中の要素が消える瞬間を掴める */}
<AnimatePresence>
{open && (
<motion.div
// key は必須。これで在/不在を追跡している
key="modal-backdrop"
className="backdrop"
initial={{ opacity: 0 }} // 出現前の状態
animate={{ opacity: 1 }} // 表示中の状態
exit={{ opacity: 0 }} // 退場時の状態(CSSでは書けない部分)
onClick={() => setOpen(false)}
>
<motion.div
key="modal-card"
className="card"
initial={{ opacity: 0, y: 20, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.96 }}
transition={{ duration: 0.22, ease: [0.16, 1, 0.3, 1] }}
onClick={(e) => e.stopPropagation()}
>
<h2>保存しました</h2>
<p>このカードは、閉じるときもちゃんとフェードアウトします。</p>
<button onClick={() => setOpen(false)}>閉じる</button>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
ポイントは3つだけ覚えてください。
AnimatePresenceで条件分岐ごと囲む。{open && (...)}の外側に置く。これが「消える瞬間」を掴むスコープになります。- 直下の子に必ず
keyを付ける。AnimatePresenceはkeyで要素の在/不在を追跡しているので、ここが無いと退場が動きません。配列のindexをkeyにしないこと(並び替えで壊れます)。 exitに「消えるときの状態」を書く。initial(出現前)・animate(表示中)・exit(退場時)の3点を宣言すれば、間の動きはライブラリが補間します。
モーダルやトーストのように「開いたものが閉じる」UIだと、mode="wait" を AnimatePresence に付けると、前の要素が消えきってから次が出ます。タブの切り替えで中身を入れ替えるときに重宝します。
レイアウトアニメ:並び替えを layout で滑らかに
もう一つCSSが苦手なのが、レイアウトの変化です。リストを並び替える、項目を1つ削除して下が詰まる、フィルターで表示数が変わる——このとき各要素が「カクッ」とワープせず、元の位置から新しい位置へスーッと動いてほしい。
これをCSSでやろうとすると、各要素の旧座標と新座標を自前で測って差分を当てる、いわゆるFLIPという手法を手で実装することになります。地獄です。Framer Motionは layout プロップ一個でこれを肩代わりしてくれます。
import { useState } from "react";
import { motion } from "motion/react";
const initial = [
{ id: 1, label: "記事を書く" },
{ id: 2, label: "レビューする" },
{ id: 3, label: "公開する" },
];
export function SortableList() {
const [items, setItems] = useState(initial);
// 配列をシャッフルするだけ。座標計算は一切しない
const shuffle = () =>
setItems((prev) => [...prev].sort(() => Math.random() - 0.5));
const remove = (id: number) =>
setItems((prev) => prev.filter((item) => item.id !== id));
return (
<div>
<button onClick={shuffle}>並び替える</button>
<ul style={{ listStyle: "none", padding: 0 }}>
{items.map((item) => (
<motion.li
key={item.id} // ここでも安定した key が必須
layout // ← これだけで位置の変化が滑らかになる
transition={{ type: "spring", stiffness: 500, damping: 40 }}
style={{
padding: "12px 16px",
marginBottom: 8,
borderRadius: 8,
background: "#eef2ff",
}}
>
{item.label}
<button onClick={() => remove(item.id)} style={{ marginLeft: 12 }}>
削除
</button>
</motion.li>
))}
</ul>
</div>
);
}
shuffle の中身を見てください。やっているのは配列を並び替えているだけで、座標の計算は一行もありません。layout プロップが、レンダリング前後の位置を勝手に測って差分をアニメーションしてくれます。transition に type: "spring" を渡すと、バネのような自然な減速になります。AnimatePresence と組み合わせれば、削除時にフェードアウトしながら下が詰まる、という合わせ技もできます。
layout は便利ですが、サイズや位置が頻繁に変わる巨大なリストに全部付けると、毎フレームの再計測でカクつくことがあります。アニメーションさせたい要素だけに絞るのがコツです。
ライブラリ要らずの選択肢:Web Animations API
「退場でもない、並び替えでもない。ただクリックでボタンを一度だけ弾ませたい」。この程度なら、ライブラリを入れる必要すらありません。ブラウザ標準のWeb Animations API(WAAPI)で足ります。element.animate() は @keyframes をJSで書けるようなもので、依存ゼロ・追加バンドルゼロです。
しかもこのAPIは finished というPromiseを返すので、「アニメーションが終わったら次の処理」が await で素直に書けます。reduce motion設定の人には動きを飛ばす、という配慮も入れた実装がこちらです。
import { useRef } from "react";
export function SaveButton() {
const ref = useRef<HTMLButtonElement>(null);
const handleClick = async () => {
const button = ref.current;
if (!button) return;
// 端末が「動きを減らす」設定なら演出はスキップ
const reduceMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
if (!reduceMotion) {
// element.animate() は Animation を返し、.finished は Promise
await button.animate(
[
{ transform: "scale(1)" },
{ transform: "scale(0.94)" },
{ transform: "scale(1)" },
],
{ duration: 180, easing: "ease-out" }
).finished;
}
// ここで実際の保存処理を呼ぶ(演出の完了を待ってから)
console.log("保存処理を実行");
};
return (
<button ref={ref} onClick={handleClick}>
保存
</button>
);
}
判断の目安はシンプルです。1要素・1回きり・状態の出し入れが要らないならWAAPI。入退場の出し入れや、複数要素の連動、レイアウトの追従が絡んだら、自前で書くと泥沼になるのでFramer Motionに任せる。この線引きで、僕はだいたい迷わなくなりました。
Claude Codeに頼むときの依頼文
ここからは実務の話です。アニメーションをClaude Codeに任せると、放っておくと古いAPIや退場の効かないコードを書いてきます。だから依頼文で先回りして縛ります。
このReactコンポーネントに開閉アニメーションを追加してください。
- ライブラリは motion パッケージ(import は "motion/react"。framer-motion ではない)を使う
- 開くときだけでなく、閉じるときの退場アニメも AnimatePresence で実装する
- prefers-reduced-motion を尊重し、その設定では動きを無効化する
- アニメーションで隠れる情報を作らない(状態はテキストでも伝える)
- 追加した依存・変更ファイル・375px幅での確認結果を最後に報告する
この依頼文がなぜ効くか。一つ目にパッケージ名を固定しているので、framer-motion の古い書き方を防げます。二つ目に退場アニメを明示しているので、冒頭の「閉じると一瞬で消える」事故を最初から潰せます。三つ目にreduce motionと情報の代替を要求しているので、アクセシビリティの抜けを後から指摘する手間が消えます。
レビューのときは、生成コードの import 行と、条件分岐が AnimatePresence の内側にあるか、key が安定しているかを真っ先に見ます。この3点が合っていれば、だいたい動きます。
僕がやらかした失敗3つ
正直に書きます。Framer Motionで何度も転びました。
ひとつ目は、AnimatePresence の外で条件分岐したこと。{open && <AnimatePresence>...</AnimatePresence>} と書いてしまい、要素が消える前に AnimatePresence ごと消えるので退場が一切動かない。AnimatePresence は条件分岐の外側に置く、と覚えてからは一発で動くようになりました。
ふたつ目は、配列のindexをkeyにしたこと。key={index} でリストを回したら、並び替えたときにアニメーションが滅茶苦茶になりました。Reactのkeyの注意点と同じで、AnimatePresence も layout も、安定したID(item.id)で要素を追跡しています。indexは並び替えで意味が変わるのでダメです。
みっつ目は、何でもFramer Motionで書いたこと。スピナーの回転までライブラリでやって、バンドルを無駄に太らせました。@keyframes 数行で済む回転に数十KBを払う意味はありません。今は「CSSで無理?」を先に通してから、ダメなものだけライブラリに回しています。
よくある質問
Q. framer-motion と motion、どっちをインストールすればいい?
今は motion です。npm install motion で入れて、import { motion } from "motion/react" で使います。framer-motion は古いパッケージ名で、ネット上の記事はまだそちらが多いので、生成コードを見たら最初に置き換えます。
Q. 退場アニメ(exit)が動かない。
だいたい原因は3つです。(1)条件分岐が AnimatePresence の内側にあるか、(2)直下の子に key が付いているか、(3)motion.div を使っているか(素の div だと効きません)。この順で確認してください。
Q. CSSのトランジションと、何が決定的に違うの?
CSSは「存在し続ける要素」のプロパティ変化しか動かせません。DOMから消える要素や、位置がレイアウトごと変わる要素は守備範囲外です。Framer Motionはその2つ、つまり退場(AnimatePresence)と位置追従(layout)を肩代わりしてくれます。
Q. バンドルサイズが心配。軽くする方法は?
まず「本当にライブラリが要るか」を疑うのが一番効きます。フェードイン・ホバー・スクロール表示はCSSで十分です。それでも入れるなら、motion は使う機能だけ読み込む設計なので、motion/react から必要なものだけimportします。単発演出はWeb Animations APIで依存ゼロにできます。
Q. SSR(Next.jsのApp Router等)で使える?
使えますが、motion のコンポーネントはクライアント側で動くので、そのファイルの先頭に "use client" を付けます。サーバーコンポーネントのままだと初期化されず、アニメーションが効きません。
まとめ:CSSで詰んだら、その一線だけ越える
アニメーションは「全部ライブラリ」でも「全部CSS」でもありません。線引きが全部です。
ホバー、フェードイン、スクロール表示、スピナー——ここはCSSの領分。軽くて確実なので、わざわざライブラリを入れない(CSSの詰まりどころは姉妹記事に集約しました)。一方、要素が消える瞬間とレイアウトが変わる動きは、CSSの設計思想の外側にあります。ここだけFramer Motionの AnimatePresence と layout を借りる。単発の演出なら、依存ゼロのWeb Animations APIで足ります。
土台の設計やパフォーマンスが気になってきたら、Claude CodeデザインシステムやClaude Codeパフォーマンス最適化も合わせて読むと、動きを足す前提の整理に役立ちます。チームでClaude Codeの使い方ごと整えたいなら、研修・相談でこの線引きを一緒に詰めます。
実際に試した結果
この記事のコードは、手元のReactプロジェクトで実際に動かして確認しました。一番効いたのは、Claude Codeへの依頼文で motion パッケージを名指ししたことです。固定しないと、半分くらいの確率で framer-motion の古い書き方が返ってきて、importの修正から始める羽目になりました。
退場アニメも、最初は条件分岐の位置で何度もハマりました。AnimatePresence を分岐の外に出した瞬間に動いたときは、正直拍子抜けしたくらいです。逆に、スクロールで一度だけ出すフェードインをFramer Motionで書いてみたら、CSS版と見た目はほぼ同じなのにバンドルだけ太りました。やっぱり「CSSで無理か?」を先に通すのが、遠回りに見えていちばん速い。ライブラリは万能薬ではなく、CSSが届かない一線を越えるための道具だ、というのが今の実感です。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Codeに1ファイルだけ直させる指示文のつくり方
「もっと良くして」で40行も変えられた失敗から学んだ、触る範囲・検証・戻し方をセットにしたClaude Code用の依頼文テンプレートを紹介します。
Claude Code の権限拒否から復旧する: 止まった理由を次の安全手順に変える
Claude Code のコマンドが拒否されたとき、焦って許可を広げずに、拒否理由、代替手順、証拠コマンド、再試行条件へ分解する方法。
Claude Codeにビルド→スモークテスト→自動修正を回させる足場の作り方
最小スモークテストの選び方、失敗ログを食わせて直させるループ、回数上限と確認ゲートで暴走を止める方法を、コピペで動くコード付きで紹介します。