Motion(旧 Framer Motion)で exit が発火しない/遅延する問題の原因切り分けガイド

Motion(旧 Framer Motion)を使ってアニメーションを実装していると、コンポーネントが消える時の exit アニメーションが思うように動作しない場面に遭遇することがあります。 画面遷移やモーダル閉じる時に、アニメーションがスキップされたり、突然消えてしまったりする経験はないでしょうか。
この記事では、exit アニメーションが発火しない、または遅延する問題の原因を体系的に切り分ける方法を解説します。
背景
Motion における exit アニメーションの仕組み
Motion は、React コンポーネントのマウント・アンマウント時にアニメーションを提供するライブラリです。 特に exit アニメーションは、コンポーネントが DOM から削除される前に実行される特別な処理となります。
mermaidflowchart TD
  mounted["コンポーネント<br/>マウント済み"] -->|条件変更| unmount["アンマウント<br/>トリガー"]
  unmount --> check["AnimatePresence<br/>検知"]
  check -->|存在する| exit["exit アニメーション<br/>実行"]
  check -->|存在しない| remove1["即座に DOM から削除"]
  exit --> complete["アニメーション完了"]
  complete --> remove2["DOM から削除"]
上記の図は、Motion における exit アニメーションの基本的なフローを示しています。
AnimatePresence コンポーネントが、子要素のアンマウントを検知してアニメーションを実行する流れですね。
exit アニメーションが重要な理由
ユーザー体験において、要素の消え方は非常に重要な要素です。 突然消えるのではなく、滑らかにフェードアウトしたりスライドアウトしたりすることで、ユーザーは何が起きているかを直感的に理解できます。
しかし、React の仮想 DOM の仕組み上、コンポーネントがアンマウントされると即座に DOM から削除されるため、exit アニメーションを実現するには特別な制御が必要になります。
課題
よくある exit アニメーション不具合のパターン
exit アニメーションが正しく動作しない場合、以下のような症状が現れます。
| # | 症状 | 発生頻度 | 影響度 | 
|---|---|---|---|
| 1 | アニメーションが全く実行されず即座に消える | ★★★ | 高 | 
| 2 | アニメーションが途中で止まる | ★★☆ | 中 | 
| 3 | 意図した遅延時間より長くかかる | ★☆☆ | 中 | 
| 4 | 複数要素で一部だけ動作しない | ★★☆ | 高 | 
これらの問題は、初心者から経験者まで、誰もが一度は遭遇する課題と言えるでしょう。
問題が発生する主な原因
exit アニメーションが正しく動作しない原因は、大きく分けて以下の 4 つのカテゴリに分類できます。
mermaidflowchart LR
  problem["exit 問題"] --> cat1["AnimatePresence<br/>の設定ミス"]
  problem --> cat2["key の設定ミス"]
  problem --> cat3["状態管理の<br/>タイミング問題"]
  problem --> cat4["CSS や<br/>親要素の制約"]
  cat1 --> issue1["未使用"]
  cat1 --> issue2["ネスト不正"]
  cat2 --> issue3["key なし"]
  cat2 --> issue4["key 重複"]
  cat3 --> issue5["即座の削除"]
  cat3 --> issue6["条件分岐ミス"]
  cat4 --> issue7["overflow hidden"]
  cat4 --> issue8["display none"]
それぞれの原因について、これから詳しく見ていきます。
解決策
原因 1:AnimatePresence が使われていない
問題の詳細
exit アニメーションを動作させるには、必ず AnimatePresence コンポーネントで囲む必要があります。
これを忘れると、React の通常の動作により、コンポーネントは即座に DOM から削除されてしまいます。
誤った実装例
typescriptimport { motion } from 'framer-motion';
function BadExample({ isVisible }: { isVisible: boolean }) {
  return (
    <>
      {isVisible && (
        <motion.div
          initial={{ opacity: 0 }}
          animate={{ opacity: 1 }}
          exit={{ opacity: 0 }}
        >
          表示される要素
        </motion.div>
      )}
    </>
  );
}
上記のコードでは、isVisible が false になった瞬間に要素が消えてしまいます。
exit プロパティは定義されていますが、AnimatePresence がないため無視されてしまうのです。
正しい実装例
typescriptimport { motion, AnimatePresence } from 'framer-motion';
function GoodExample({
  isVisible,
}: {
  isVisible: boolean;
}) {
  return (
    <AnimatePresence>
      {isVisible && (
        <motion.div
          initial={{ opacity: 0 }}
          animate={{ opacity: 1 }}
          exit={{ opacity: 0 }}
        >
          表示される要素
        </motion.div>
      )}
    </AnimatePresence>
  );
}
AnimatePresence で囲むことで、Motion は要素がアンマウントされるタイミングを検知し、exit アニメーションを実行してから DOM から削除します。
検証方法
以下のチェックリストで確認しましょう。
-  AnimatePresenceをインポートしているか
-  条件付きレンダリングを AnimatePresenceで囲んでいるか
-  AnimatePresenceは motion コンポーネントの直接の親になっているか
原因 2:key プロパティが適切に設定されていない
問題の詳細
複数の要素を切り替える場合や、リストをレンダリングする場合、一意な key プロパティが必須です。
key がないと、React は要素の同一性を判断できず、アンマウントとマウントではなく更新として処理してしまいます。
誤った実装例(key なし)
typescriptimport { motion, AnimatePresence } from 'framer-motion';
function BadTabExample({
  currentTab,
}: {
  currentTab: string;
}) {
  return (
    <AnimatePresence>
      {currentTab === 'home' && (
        <motion.div
          initial={{ x: 300 }}
          animate={{ x: 0 }}
          exit={{ x: -300 }}
        >
          ホームタブ
        </motion.div>
      )}
      {currentTab === 'profile' && (
        <motion.div
          initial={{ x: 300 }}
          animate={{ x: 0 }}
          exit={{ x: -300 }}
        >
          プロフィールタブ
        </motion.div>
      )}
    </AnimatePresence>
  );
}
この実装では、タブを切り替えてもアニメーションが実行されません。 React は両方の要素を別物として認識できず、単に内容が変わっただけと判断してしまうのです。
正しい実装例(key あり)
typescriptimport { motion, AnimatePresence } from 'framer-motion';
function GoodTabExample({
  currentTab,
}: {
  currentTab: string;
}) {
  return (
    <AnimatePresence mode='wait'>
      {currentTab === 'home' && (
        <motion.div
          key='home'
          initial={{ x: 300 }}
          animate={{ x: 0 }}
          exit={{ x: -300 }}
        >
          ホームタブ
        </motion.div>
      )}
      {currentTab === 'profile' && (
        <motion.div
          key='profile'
          initial={{ x: 300 }}
          animate={{ x: 0 }}
          exit={{ x: -300 }}
        >
          プロフィールタブ
        </motion.div>
      )}
    </AnimatePresence>
  );
}
各要素に一意な key を設定することで、Motion は要素の切り替えを正しく認識できます。
さらに mode="wait" を指定することで、exit アニメーションが完了してから次の要素が表示されます。
AnimatePresence の mode オプション
AnimatePresence には、複数要素の切り替え方を制御する mode オプションがあります。
| # | mode | 動作 | 使いどころ | 
|---|---|---|---|
| 1 | sync(デフォルト) | exit と enter が同時実行 | スライドショー、オーバーレイ | 
| 2 | wait | exit 完了後に enter 実行 | タブ切り替え、ページ遷移 | 
| 3 | popLayout | レイアウトシフト最小化 | リスト項目の削除 | 
目的に応じて適切な mode を選択することで、より自然なアニメーションを実現できます。
原因 3:状態管理のタイミング問題
問題の詳細
親コンポーネントがアンマウントされると、その子要素も即座に削除されます。 そのため、親がアンマウントされる前に exit アニメーションを完了させる必要があります。
mermaidsequenceDiagram
  participant State as 状態管理
  participant Parent as 親コンポーネント
  participant Child as 子 motion 要素
  participant DOM as DOM
  State->>Parent: isOpen = false
  rect rgb(255, 200, 200)
  note right of Parent: 誤ったパターン
  Parent--xChild: 即座にアンマウント
  Child--xDOM: アニメーション実行できず
  end
  rect rgb(200, 255, 200)
  note right of Parent: 正しいパターン
  Parent->>Child: AnimatePresence 経由
  Child->>DOM: exit アニメーション実行
  Child->>Parent: 完了通知
  Parent->>DOM: アンマウント
  end
上記の図は、状態変更から DOM 削除までの処理フローを示しています。 AnimatePresence を介することで、アニメーション完了を待ってから削除が実行されますね。
誤った実装例(親が先に消える)
typescriptimport { motion, AnimatePresence } from 'framer-motion';
function BadModalExample() {
  const [isOpen, setIsOpen] = useState(true);
  return (
    <>
      {isOpen && (
        <div className='modal-overlay'>
          <AnimatePresence>
            <motion.div
              initial={{ scale: 0.8, opacity: 0 }}
              animate={{ scale: 1, opacity: 1 }}
              exit={{ scale: 0.8, opacity: 0 }}
              className='modal-content'
            >
              モーダルの内容
              <button onClick={() => setIsOpen(false)}>
                閉じる
              </button>
            </motion.div>
          </AnimatePresence>
        </div>
      )}
    </>
  );
}
このコードでは、isOpen が false になると modal-overlay ごと即座に消えてしまいます。
子要素の exit アニメーションは実行される暇がありません。
正しい実装例(AnimatePresence で全体を囲む)
typescriptimport { motion, AnimatePresence } from 'framer-motion';
function GoodModalExample() {
  const [isOpen, setIsOpen] = useState(true);
  return (
    <AnimatePresence>
      {isOpen && (
        <motion.div
          className='modal-overlay'
          initial={{ opacity: 0 }}
          animate={{ opacity: 1 }}
          exit={{ opacity: 0 }}
        >
          <motion.div
            initial={{ scale: 0.8, opacity: 0 }}
            animate={{ scale: 1, opacity: 1 }}
            exit={{ scale: 0.8, opacity: 0 }}
            className='modal-content'
          >
            モーダルの内容
            <button onClick={() => setIsOpen(false)}>
              閉じる
            </button>
          </motion.div>
        </motion.div>
      )}
    </AnimatePresence>
  );
}
条件分岐のすぐ内側に AnimatePresence を配置することで、親要素も含めて適切に exit アニメーションが実行されます。
onExitComplete コールバックの活用
exit アニメーション完了後に追加の処理を実行したい場合は、onExitComplete コールバックを使用します。
typescriptimport { motion, AnimatePresence } from 'framer-motion';
function ModalWithCallback() {
  const [isOpen, setIsOpen] = useState(true);
  const [shouldRender, setShouldRender] = useState(true);
  const handleExitComplete = () => {
    console.log('exit アニメーション完了');
    setShouldRender(false);
    // クリーンアップ処理など
  };
  return (
    <AnimatePresence onExitComplete={handleExitComplete}>
      {isOpen && shouldRender && (
        <motion.div
          initial={{ opacity: 0 }}
          animate={{ opacity: 1 }}
          exit={{ opacity: 0 }}
        >
          モーダルの内容
        </motion.div>
      )}
    </AnimatePresence>
  );
}
このコールバックを使うことで、アニメーション完了を確実に検知して後続処理を実行できます。
原因 4:CSS や親要素の制約
問題の詳細
親要素に特定の CSS プロパティが設定されていると、exit アニメーションが見えなくなることがあります。
特に overflow: hidden や display: none は要注意です。
問題となる CSS プロパティ
| # | プロパティ | 問題 | 影響 | 
|---|---|---|---|
| 1 | overflow: hidden | 要素が親の範囲外に出るとクリップされる | スライドアニメーション | 
| 2 | display: none | 子要素も強制的に非表示になる | すべてのアニメーション | 
| 3 | position: fixed の誤用 | レイアウト崩れで見えなくなる | 位置アニメーション | 
| 4 | z-index の競合 | 他要素に隠れる | すべてのアニメーション | 
これらのプロパティが親要素に設定されていないか、DevTools でチェックする習慣をつけましょう。
誤った CSS 例
css/* 親要素の CSS */
.container {
  overflow: hidden; /* これが原因でスライドアニメーションが見えない */
  width: 300px;
  height: 200px;
}
typescript// この motion.div のスライドアウトが見えない
function BadCSSExample() {
  const [isVisible, setIsVisible] = useState(true);
  return (
    <div className='container'>
      <AnimatePresence>
        {isVisible && (
          <motion.div
            initial={{ x: 0 }}
            exit={{ x: 400 }} // 親の範囲外に出るため見えない
          >
            内容
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}
正しい CSS 例
css/* overflow を削除するか、適切な値に変更 */
.container {
  overflow: visible; /* または overflow-x: visible */
  width: 300px;
  height: 200px;
}
/* または AnimatePresence を親の外に配置 */
.wrapper {
  position: relative;
  /* overflow 制限なし */
}
.container {
  overflow: hidden;
  width: 300px;
  height: 200px;
}
typescript// AnimatePresence を overflow 制約のない親に配置
function GoodCSSExample() {
  const [isVisible, setIsVisible] = useState(true);
  return (
    <div className='wrapper'>
      <AnimatePresence>
        {isVisible && (
          <motion.div initial={{ x: 0 }} exit={{ x: 400 }}>
            内容
          </motion.div>
        )}
      </AnimatePresence>
      <div className='container'>他のコンテンツ</div>
    </div>
  );
}
構造を見直して、アニメーションする要素が CSS 制約の影響を受けないように配置することが重要です。
原因 5:transition の設定ミス
問題の詳細
exit アニメーションの継続時間や easing が適切に設定されていないと、アニメーションが途中で止まったり、遅延して見えたりします。
transition の主要プロパティ
typescriptimport { motion, AnimatePresence } from 'framer-motion';
function TransitionExample() {
  const [isVisible, setIsVisible] = useState(true);
  return (
    <AnimatePresence>
      {isVisible && (
        <motion.div
          initial={{ opacity: 0, y: -20 }}
          animate={{ opacity: 1, y: 0 }}
          exit={{ opacity: 0, y: 20 }}
          transition={{
            duration: 0.3, // アニメーション時間(秒)
            ease: 'easeInOut', // easing 関数
            delay: 0, // 開始遅延
          }}
        >
          内容
        </motion.div>
      )}
    </AnimatePresence>
  );
}
各プロパティの意味を理解して、目的に応じた値を設定しましょう。
プロパティ別の設定方法
typescriptimport { motion, AnimatePresence } from 'framer-motion';
function AdvancedTransitionExample() {
  const [isVisible, setIsVisible] = useState(true);
  return (
    <AnimatePresence>
      {isVisible && (
        <motion.div
          initial={{ opacity: 0, scale: 0.8, rotate: -10 }}
          animate={{ opacity: 1, scale: 1, rotate: 0 }}
          exit={{ opacity: 0, scale: 0.8, rotate: 10 }}
          transition={{
            // プロパティごとに異なる transition を設定
            opacity: { duration: 0.2 },
            scale: { duration: 0.3, ease: 'easeOut' },
            rotate: {
              duration: 0.4,
              ease: [0.43, 0.13, 0.23, 0.96],
            },
          }}
        >
          内容
        </motion.div>
      )}
    </AnimatePresence>
  );
}
プロパティごとに異なるタイミングを設定することで、より複雑で洗練されたアニメーションを作れます。
よく使う easing 関数
| # | easing | 動き | 用途 | 
|---|---|---|---|
| 1 | linear | 等速 | 回転、ローディング | 
| 2 | easeIn | 加速 | 要素の消失 | 
| 3 | easeOut | 減速 | 要素の出現 | 
| 4 | easeInOut | 加速 → 減速 | 移動、変形 | 
| 5 | [0.43, 0.13, 0.23, 0.96] | カスタム(cubic-bezier) | 独自の動き | 
easing によって、アニメーションの「感じ」が大きく変わります。
具体例
実践例 1:モーダルの実装
実際のアプリケーションでよく使われるモーダルダイアログを、exit アニメーション付きで実装してみましょう。
コンポーネント構成
mermaidflowchart TD
  App["App<br/>(状態管理)"] --> AP["AnimatePresence"]
  AP --> Overlay["motion.div<br/>オーバーレイ"]
  Overlay --> Content["motion.div<br/>コンテンツ"]
  Content --> Button["閉じるボタン"]
  style AP fill:#e1f5ff
  style Overlay fill:#fff4e1
  style Content fill:#f0f0f0
上記の図は、モーダルコンポーネントの階層構造を示しています。 AnimatePresence が最上位に配置され、全体のアニメーションを制御していますね。
型定義とインターフェース
typescriptimport { motion, AnimatePresence } from 'framer-motion';
import { ReactNode } from 'react';
interface ModalProps {
  isOpen: boolean;
  onClose: () => void;
  children: ReactNode;
}
Props の型を明確に定義することで、TypeScript の恩恵を最大限受けられます。
オーバーレイのアニメーション定義
typescript// オーバーレイ(背景)のアニメーション設定
const overlayVariants = {
  hidden: {
    opacity: 0,
  },
  visible: {
    opacity: 1,
    transition: {
      duration: 0.2,
    },
  },
  exit: {
    opacity: 0,
    transition: {
      duration: 0.2,
      delay: 0.1, // コンテンツが先に消えるよう遅延
    },
  },
};
variants を使うことで、アニメーション定義を再利用しやすくなります。
コンテンツのアニメーション定義
typescript// モーダルコンテンツのアニメーション設定
const contentVariants = {
  hidden: {
    opacity: 0,
    scale: 0.8,
    y: -50,
  },
  visible: {
    opacity: 1,
    scale: 1,
    y: 0,
    transition: {
      type: 'spring', // スプリングアニメーション
      damping: 25, // バネの減衰
      stiffness: 300, // バネの硬さ
    },
  },
  exit: {
    opacity: 0,
    scale: 0.8,
    y: 50,
    transition: {
      duration: 0.2,
      ease: 'easeIn',
    },
  },
};
スプリングアニメーションを使うことで、より自然な動きを実現できます。
Modal コンポーネント本体
typescriptexport function Modal({
  isOpen,
  onClose,
  children,
}: ModalProps) {
  return (
    <AnimatePresence>
      {isOpen && (
        // オーバーレイ
        <motion.div
          className='modal-overlay'
          variants={overlayVariants}
          initial='hidden'
          animate='visible'
          exit='exit'
          onClick={onClose} // 背景クリックで閉じる
          style={{
            position: 'fixed',
            top: 0,
            left: 0,
            right: 0,
            bottom: 0,
            backgroundColor: 'rgba(0, 0, 0, 0.5)',
            display: 'flex',
            alignItems: 'center',
            justifyContent: 'center',
            zIndex: 1000,
          }}
        >
          {/* コンテンツ */}
          <motion.div
            className='modal-content'
            variants={contentVariants}
            initial='hidden'
            animate='visible'
            exit='exit'
            onClick={(e) => e.stopPropagation()} // イベント伝播を止める
            style={{
              backgroundColor: 'white',
              borderRadius: '8px',
              padding: '24px',
              maxWidth: '500px',
              width: '90%',
              boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
            }}
          >
            {children}
          </motion.div>
        </motion.div>
      )}
    </AnimatePresence>
  );
}
オーバーレイとコンテンツを別々の motion.div にすることで、それぞれ独立したアニメーションを実現しています。
使用例
typescriptimport { useState } from 'react';
import { Modal } from './Modal';
function App() {
  const [isModalOpen, setIsModalOpen] = useState(false);
  return (
    <div>
      <button onClick={() => setIsModalOpen(true)}>
        モーダルを開く
      </button>
      <Modal
        isOpen={isModalOpen}
        onClose={() => setIsModalOpen(false)}
      >
        <h2>モーダルタイトル</h2>
        <p>モーダルの内容がここに入ります。</p>
        <button onClick={() => setIsModalOpen(false)}>
          閉じる
        </button>
      </Modal>
    </div>
  );
}
シンプルな状態管理で、モーダルの開閉を制御できます。
実践例 2:タブ切り替えの実装
次に、複数のコンテンツをタブで切り替える UI を実装します。
ここでは mode="wait" を活用して、スムーズな切り替えを実現しましょう。
タブデータの型定義
typescriptinterface Tab {
  id: string;
  label: string;
  content: string;
}
const tabs: Tab[] = [
  {
    id: 'home',
    label: 'ホーム',
    content: 'ホームの内容です',
  },
  { id: 'about', label: '概要', content: '概要の内容です' },
  {
    id: 'contact',
    label: '連絡先',
    content: '連絡先の内容です',
  },
];
タブデータを配列で管理することで、拡張しやすい設計になります。
タブコンポーネントの実装
typescriptimport { motion, AnimatePresence } from 'framer-motion';
import { useState } from 'react';
function TabComponent() {
  const [activeTab, setActiveTab] = useState('home');
  // 現在選択されているタブの情報を取得
  const currentTab = tabs.find(
    (tab) => tab.id === activeTab
  );
  return (
    <div style={{ maxWidth: '600px', margin: '0 auto' }}>
      {/* タブボタン群 */}
      <div
        style={{
          display: 'flex',
          gap: '8px',
          marginBottom: '16px',
        }}
      >
        {tabs.map((tab) => (
          <button
            key={tab.id}
            onClick={() => setActiveTab(tab.id)}
            style={{
              padding: '8px 16px',
              backgroundColor:
                activeTab === tab.id
                  ? '#007bff'
                  : '#e0e0e0',
              color:
                activeTab === tab.id ? 'white' : 'black',
              border: 'none',
              borderRadius: '4px',
              cursor: 'pointer',
            }}
          >
            {tab.label}
          </button>
        ))}
      </div>
      {/* タブコンテンツ */}
      <AnimatePresence mode='wait'>
        <motion.div
          key={activeTab} // key が変わることで切り替えを検知
          initial={{ opacity: 0, x: 20 }}
          animate={{ opacity: 1, x: 0 }}
          exit={{ opacity: 0, x: -20 }}
          transition={{ duration: 0.2 }}
          style={{
            padding: '16px',
            backgroundColor: '#f5f5f5',
            borderRadius: '4px',
          }}
        >
          <h3>{currentTab?.label}</h3>
          <p>{currentTab?.content}</p>
        </motion.div>
      </AnimatePresence>
    </div>
  );
}
mode="wait" により、前のタブが完全に消えてから次のタブが表示されます。
実践例 3:リスト項目の削除
最後に、リストから項目を削除する際のアニメーションを実装します。 複数の項目が同時にアニメーションする場合の注意点を見ていきましょう。
リスト項目の型定義
typescriptinterface ListItem {
  id: number;
  title: string;
  description: string;
}
一意な id を持たせることで、React が各項目を正しく識別できます。
リストコンポーネントの状態管理
typescriptimport { motion, AnimatePresence } from 'framer-motion';
import { useState } from 'react';
function AnimatedList() {
  const [items, setItems] = useState<ListItem[]>([
    { id: 1, title: '項目 1', description: '説明文 1' },
    { id: 2, title: '項目 2', description: '説明文 2' },
    { id: 3, title: '項目 3', description: '説明文 3' },
  ]);
  const handleRemove = (id: number) => {
    setItems((prev) =>
      prev.filter((item) => item.id !== id)
    );
  };
  return (
    <div style={{ maxWidth: '600px', margin: '0 auto' }}>
      <h2>アニメーション付きリスト</h2>
      <AnimatePresence>
        {items.map((item) => (
          <motion.div
            key={item.id} // 必須:一意な key
            layout // レイアウトアニメーション
            initial={{ opacity: 0, height: 0 }}
            animate={{ opacity: 1, height: 'auto' }}
            exit={{ opacity: 0, height: 0 }}
            transition={{ duration: 0.3 }}
            style={{
              marginBottom: '8px',
              padding: '16px',
              backgroundColor: '#f0f0f0',
              borderRadius: '4px',
              overflow: 'hidden',
            }}
          >
            <h3>{item.title}</h3>
            <p>{item.description}</p>
            <button onClick={() => handleRemove(item.id)}>
              削除
            </button>
          </motion.div>
        ))}
      </AnimatePresence>
    </div>
  );
}
layout プロパティを追加することで、他の項目が滑らかに移動します。
layout アニメーションの最適化
typescript// layout アニメーションを細かく制御
function OptimizedAnimatedList() {
  const [items, setItems] = useState<ListItem[]>([
    { id: 1, title: '項目 1', description: '説明文 1' },
    { id: 2, title: '項目 2', description: '説明文 2' },
    { id: 3, title: '項目 3', description: '説明文 3' },
  ]);
  const handleRemove = (id: number) => {
    setItems((prev) =>
      prev.filter((item) => item.id !== id)
    );
  };
  return (
    <div style={{ maxWidth: '600px', margin: '0 auto' }}>
      <h2>最適化されたリスト</h2>
      <AnimatePresence mode='popLayout'>
        {items.map((item) => (
          <motion.div
            key={item.id}
            layout='position' // position のみアニメーション(サイズは除外)
            initial={{ opacity: 0, scale: 0.8 }}
            animate={{ opacity: 1, scale: 1 }}
            exit={{ opacity: 0, scale: 0.8 }}
            transition={{
              opacity: { duration: 0.2 },
              scale: { duration: 0.2 },
              layout: { duration: 0.3 }, // layout アニメーションの時間
            }}
            style={{
              marginBottom: '8px',
              padding: '16px',
              backgroundColor: '#f0f0f0',
              borderRadius: '4px',
            }}
          >
            <h3>{item.title}</h3>
            <p>{item.description}</p>
            <button onClick={() => handleRemove(item.id)}>
              削除
            </button>
          </motion.div>
        ))}
      </AnimatePresence>
    </div>
  );
}
layout="position" を使うことで、パフォーマンスを向上させつつ自然なアニメーションを実現できます。
まとめ
Motion(旧 Framer Motion)で exit アニメーションが発火しない、または遅延する問題の原因と解決策を体系的に解説しました。
重要なポイントの再確認
以下のチェックリストを使って、実装を確認しましょう。
| # | チェック項目 | 重要度 | 
|---|---|---|
| 1 | AnimatePresence を使用しているか | ★★★ | 
| 2 | 一意な key を設定しているか | ★★★ | 
| 3 | 親要素が先にアンマウントされていないか | ★★★ | 
| 4 | 親要素に overflow: hidden がないか | ★★☆ | 
| 5 | transition の設定は適切か | ★☆☆ | 
| 6 | mode オプションは目的に合っているか | ★★☆ | 
これらのポイントを押さえることで、ほとんどの exit アニメーション問題を解決できます。
トラブルシューティングの手順
問題が発生した場合は、以下の順序で原因を切り分けていくと効率的です。
- AnimatePresence の確認 - まず最初にチェック
- key の確認 - 複数要素の場合は必須
- 親要素のライフサイクル確認 - React DevTools で検証
- CSS の確認 - ブラウザの DevTools で確認
- transition の確認 - 時間や easing を調整
段階的に確認することで、原因を特定しやすくなりますね。
パフォーマンスの考慮
アニメーションはユーザー体験を向上させますが、過度な使用はパフォーマンスを低下させる可能性があります。
- アニメーション時間は 0.2〜0.4 秒程度 が一般的
- 同時にアニメーションする要素は 10 個以内 を目安に
- 複雑な transform より opacity の方が軽量 です
- will-change プロパティ の使用も検討してみましょう
これらのベストプラクティスを意識することで、滑らかで快適なアニメーションを実現できます。
Motion の exit アニメーションは、正しく理解して実装すれば、アプリケーションのユーザー体験を大きく向上させる強力なツールとなります。 この記事が、皆さんの開発の助けになれば幸いです。
関連リンク
 article article- Motion(旧 Framer Motion)で exit が発火しない/遅延する問題の原因切り分けガイド
 article article- Motion(旧 Framer Motion)で学ぶ物理ベースアニメ:バネ定数・減衰・質量の直感入門
 article article- Motion(旧 Framer Motion)デザインレビュー運用:Figma パラメータ同期と差分共有のワークフロー
 article article- Motion(旧 Framer Motion)アニメオーケストレーション設計:timeline・遅延・相互依存の整理術
 article article- Motion(旧 Framer Motion)useAnimate/useMotionValueEvent 速習チートシート
 article article- Motion(旧 Framer Motion)× Vite/Next/Turbopack:ツリーシェイク最適化とバンドル最小化の初期設定
 article article- MySQL ERROR 1449 対策:DEFINER 不明でビューやトリガーが壊れた時の復旧手順
 article article- Cursor で差分が崩れる/意図しない大量変更が入るときの復旧プレイブック
 article article- Motion(旧 Framer Motion)で exit が発火しない/遅延する問題の原因切り分けガイド
 article article- JavaScript 時刻の落とし穴大全:タイムゾーン/DST/うるう秒の実務対策
 article article- Cline が差分を誤適用する時:改行コード・Prettier・改フォーマット問題の解決
 article article- htmx で二重送信が起きる/起きない問題の完全対処:trigger と disable パターン
 blog blog- iPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
 blog blog- Googleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
 blog blog- 【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
 blog blog- Googleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
 blog blog- Pixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
 blog blog- フロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
 review review- 今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
 review review- ついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
 review review- 愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
 review review- 週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
 review review- 新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
 review review- 科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来