ReactとMotionで表示・非表示にアニメーションを付ける方法
React で {isOpen && <div>…</div>} と書いた要素は、isOpen が false になった瞬間に画面から消えます。Motion の AnimatePresence で囲み、exit に消えるときの見た目を書くと、要素はアニメーションを終えてから消えるようになります。
検証環境は Ubuntu 24.04、Node.js 22.23.4 です。motion 14.10.1(中で使っている framer-motion も 14.10.1)、react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2 を使い、.tsx を tsc で型チェックしてから esbuild でビルドしました。ブラウザは Playwright 1.63.0 で動かした Chromium 153(ヘッドレス)だけです。Firefox、Safari、実機のスマートフォン、Next.js などでのサーバーサイドレンダリングは試していません。motion.dev の公式ドキュメントは検証環境のネットワークから開けなかったため、API の説明は npm パッケージに入っている README と型定義のコメントで確かめました。
motion を入れて motion/react から読み込む
Framer Motion は、いまは Motion という名前になっています。Motion の README には、framer-motion ではなく motion/react から読み込むように書かれています。
npm install motion
import { AnimatePresence, motion } from 'motion/react';
開くときと閉じるときにアニメーションを付ける
ボタンを押すと、お知らせの枠が上から下りてきながら現れ、もう一度押すと上へ戻りながら消えるようにします。
// Toggle.tsx
import { useState } from 'react';
import { AnimatePresence, motion } from 'motion/react';
export function Toggle() {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<button type="button" onClick={() => setIsOpen((v) => !v)}>
{isOpen ? '閉じる' : '開く'}
</button>
<AnimatePresence>
{isOpen && (
<motion.div
key="panel"
className="panel"
initial={{ opacity: 0, y: -12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
transition={{ duration: 0.3 }}
>
お知らせの本文です。
</motion.div>
)}
</AnimatePresence>
</div>
);
}
div の代わりに motion.div を使い、3つの状態を書きます。initial は現れる直前の見た目、animate は表示中の見た目、exit は消え終わったときの見た目です。y は縦方向の移動量(px)で、transform に変換されます。transition の duration は秒で指定します。

検証環境で、閉じるボタンを押してから毎フレーム測ったところ、枠は DOM に残ったまま、不透明度が 1 → 0.74(53ms)→ 0.31(153ms)→ 0.04(253ms)と下がり、縦の位置も 0 から −11.5px まで動きました。枠が DOM から消えたのは、押してから 319ms 後です。上の画像でも、ボタンの文字は押した瞬間に「開く」へ変わっていて、state はもう false です。それでも、AnimatePresence が exit のアニメーションを終えるまで要素を残しています。
AnimatePresence は条件の外に置く
exit の型定義のコメントには、exit を使う要素は AnimatePresence のすぐ下にある、最初の motion の要素でなければならない、と書かれています。React はアニメーションが終わるまでアンマウントを待てないため、という理由も添えられています。
次の2つの書き方では、exit を書いても消えるアニメーションになりません。
// AnimatePresence で囲んでいない
{isOpen && (
<motion.div className="panel" exit={{ opacity: 0 }} /* initial・animate・transition は省略 */>
お知らせの本文です。
</motion.div>
)}
// AnimatePresence ごと条件の中に入れている
{isOpen && (
<AnimatePresence>
<motion.div key="panel" className="panel" exit={{ opacity: 0 }} /* initial・animate・transition は省略 */>
お知らせの本文です。
</motion.div>
</AnimatePresence>
)}
検証環境では、どちらもボタンを押した直後(2〜3ms 後)の最初のフレームで、枠が DOM からなくなっていました。2つ目の書き方では、isOpen が false になると AnimatePresence 自体も外れるので、要素を残しておく AnimatePresence もいっしょにアンマウントされます。
AnimatePresence は常に描画しておき、その中で {isOpen && …} のように子を出し入れします。
中身を入れ替えるときは key を変える
入力の手順を1つずつ見せる表示のように、同じ場所の中身を入れ替えるときも、AnimatePresence を使えます。ここでは「次へ」を押すたびに、古い手順が左へ消え、新しい手順が右から現れるようにします。
// Steps.tsx
import { useState } from 'react';
import { AnimatePresence, motion } from 'motion/react';
const messages = ['1. 名前を入力します', '2. メールアドレスを入力します', '3. 内容を確認します'];
export function Steps() {
const [step, setStep] = useState(0);
return (
<div>
<button type="button" onClick={() => setStep((s) => (s + 1) % messages.length)}>
次へ
</button>
<AnimatePresence mode="wait">
<motion.div
key={step}
className="panel"
initial={{ opacity: 0, x: 24 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -24 }}
transition={{ duration: 0.3 }}
>
{messages[step]}
</motion.div>
</AnimatePresence>
</div>
);
}
ポイントは key={step} です。key が変わると、React は古い要素を外して新しい要素を作ります(React のドキュメントの、state の保持とリセットの説明を参照)。AnimatePresence はその外された要素を残して、exit のアニメーションを動かします。
試しに key を変わらない値(key="same")にしたところ、「次へ」を押した直後のフレームで文字が「2. メールアドレスを入力します」に変わり、不透明度は 1 のまま、位置も動きませんでした。key が同じなので、React は同じ要素のまま中の文字だけを書き換えています。
mode で、消える要素と現れる要素の重なり方を選ぶ
AnimatePresence の mode には3つの値があります。型定義のコメントでは、次のように説明されています。
"sync"(既定):要素が追加・削除されたら、すぐに出入りのアニメーションを始める"wait":一度に1つだけ描画し、消える要素が消え終わってから次の要素を出す"popLayout":消える要素をレイアウトから外し、隣の要素がすぐに新しい位置へ移れるようにする
上の Steps で mode だけを変えて、「次へ」を押したあとの不透明度を測ると、次のようになりました。

"sync" では、押した直後から約 310ms のあいだ、古い手順と新しい手順の2つの枠が同時に DOM にありました。クリックの 100ms 後に位置を測ると、古い枠の上端が 78px、新しい枠の上端が 148px で、新しい枠は古い枠の下に並んでいます。古い枠が消えると、新しい枠は 78px の位置へ上がりました。同じ場所で入れ替えるつもりでも、途中で縦に2つ並んでしまいます。
"wait" では、枠は常に1つでした。新しい手順が現れ始めたのは古い手順が消え終わった約 320ms 後で、不透明度が 1 になったのは 625ms 後です("sync" では 321ms 後)。位置は、測ったクリック前・100ms 後・終了後のどれも 78px でしたが、入れ替えにかかる時間は "sync" の約2倍になります。
"popLayout" では、"sync" と同じく2つの枠が同時にありました。ただし、クリックの 100ms 後には、消えていく枠に position: absolute が付き、2つの枠の上端はどちらも 78px でした。古い枠が新しい枠に重なったまま消えていくので、縦に並びません。
最初の表示ではアニメーションさせない
motion.div は、最初に描画されたときにも initial から animate へアニメーションします。ページを開いた時点で表示しておく要素なら、AnimatePresence に initial={false} を付けると、最初の表示のアニメーションだけを止められます(型定義のコメントにある説明です)。
<AnimatePresence initial={false}>
{isOpen && (
<motion.div
key="panel"
className="panel"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
>
お知らせの本文です。
</motion.div>
)}
</AnimatePresence>
最初から isOpen を true にしておき、読み込み直後から測ったところ、initial={false} がないときは、枠が見つかった最初のフレームで不透明度 0.07、その 150ms 後に 0.73 と、だんだん現れました。initial={false} を付けると、最初のフレームから 1 でした。付けたあとも、閉じるとき(押して 149ms 後に不透明度 0.34)と、もう一度開くとき(150ms 後に 0.64)のアニメーションは動きました。
消え終わってから次の処理をする
AnimatePresence の onExitComplete は、消えていく要素がすべてアニメーションを終えたときに呼ばれます。
const [log, setLog] = useState<string[]>([]);
// …
<AnimatePresence onExitComplete={() => setLog((l) => [...l, '消え終わった'])}>
{isOpen && (
<motion.div
key="panel"
className="panel"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
>
お知らせの本文です。
</motion.div>
)}
</AnimatePresence>
<p>{log.join('、')}</p>
検証環境では、閉じるボタンを押して 327ms 後に枠が DOM から消え、その後の画面には「消え終わった」が出ていました。isOpen を false にした時点ではなく、消えるアニメーションが終わった時点で処理を実行できます。
動きを減らす設定のときは移動をやめる
OS やブラウザで動きを減らす設定(CSS では prefers-reduced-motion で読み取れる設定)をしている人に向けて、移動のアニメーションを止めることもできます。使うのは MotionConfig の reducedMotion です。型定義のコメントには、端末の動きを減らす設定に従って transform のアニメーションを止める、と書かれています(値は "always"・"never"・"user" の3つ)。ここでは "user" にして試しました。
import { MotionConfig } from 'motion/react';
<MotionConfig reducedMotion="user">
<Toggle />
</MotionConfig>
Playwright で prefers-reduced-motion: reduce にして Toggle を開いたところ、不透明度は 0 → 0.67(155ms)→ 1 と、ふだんと同じように変わりました。一方、縦の位置は押した直後の −12px から、55ms 後にはもう 0(transform なし)になっていて、下りてくる動きはありませんでした。閉じるときも、位置は押した直後に −12px へ移り、そのまま不透明度だけが下がって消えました。フェードは残したまま、移動だけをやめる動きです。





