ReactとMotionで表示・非表示にアニメーションを付ける方法

​​React​Motion,TypeScript,アニメーション

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 から読み込むように書かれています。

sh
npm install motion
ts
import { AnimatePresence, motion } from 'motion/react';

開くときと閉じるときにアニメーションを付ける

ボタンを押すと、お知らせの枠が上から下りてきながら現れ、もう一度押すと上へ戻りながら消えるようにします。

tsx
// 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 は秒で指定します。

開くボタンを押すと枠が上から下りてきて現れ、閉じるボタンを押すと上へ戻りながら薄くなって消える様子
ボタンを4回押して、開く・閉じるを2回ずつくり返したところ

検証環境で、閉じるボタンを押してから毎フレーム測ったところ、枠は 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 を書いても消えるアニメーションになりません。

tsx
// 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 を使えます。ここでは「次へ」を押すたびに、古い手順が左へ消え、新しい手順が右から現れるようにします。

tsx
// 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 で、消える要素の線が下がるのと同時に現れる要素の線が上がる。下のグラフは wait で、消える要素が 0 になったあとに現れる要素の線が上がり始める
mode が sync と wait のときの、消える要素と現れる要素の不透明度

"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} を付けると、最初の表示のアニメーションだけを止められます(型定義のコメントにある説明です)。

tsx
<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 は、消えていく要素がすべてアニメーションを終えたときに呼ばれます。

tsx
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" にして試しました。

tsx
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 へ移り、そのまま不透明度だけが下がって消えました。フェードは残したまま、移動だけをやめる動きです。

​Posted by 86