T-CREATOR

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

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>
      )}
    </>
  );
}

上記のコードでは、isVisiblefalse になった瞬間に要素が消えてしまいます。 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動作使いどころ
1sync(デフォルト)exit と enter が同時実行スライドショー、オーバーレイ
2waitexit 完了後に enter 実行タブ切り替え、ページ遷移
3popLayoutレイアウトシフト最小化リスト項目の削除

目的に応じて適切な 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>
      )}
    </>
  );
}

このコードでは、isOpenfalse になると 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: hiddendisplay: none は要注意です。

問題となる CSS プロパティ

#プロパティ問題影響
1overflow: hidden要素が親の範囲外に出るとクリップされるスライドアニメーション
2display: none子要素も強制的に非表示になるすべてのアニメーション
3position: fixed の誤用レイアウト崩れで見えなくなる位置アニメーション
4z-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動き用途
1linear等速回転、ローディング
2easeIn加速要素の消失
3easeOut減速要素の出現
4easeInOut加速 → 減速移動、変形
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',
    },
  },
};

スプリングアニメーションを使うことで、より自然な動きを実現できます。

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 アニメーションが発火しない、または遅延する問題の原因と解決策を体系的に解説しました。

重要なポイントの再確認

以下のチェックリストを使って、実装を確認しましょう。

#チェック項目重要度
1AnimatePresence を使用しているか★★★
2一意な key を設定しているか★★★
3親要素が先にアンマウントされていないか★★★
4親要素に overflow: hidden がないか★★☆
5transition の設定は適切か★☆☆
6mode オプションは目的に合っているか★★☆

これらのポイントを押さえることで、ほとんどの exit アニメーション問題を解決できます。

トラブルシューティングの手順

問題が発生した場合は、以下の順序で原因を切り分けていくと効率的です。

  1. AnimatePresence の確認 - まず最初にチェック
  2. key の確認 - 複数要素の場合は必須
  3. 親要素のライフサイクル確認 - React DevTools で検証
  4. CSS の確認 - ブラウザの DevTools で確認
  5. transition の確認 - 時間や easing を調整

段階的に確認することで、原因を特定しやすくなりますね。

パフォーマンスの考慮

アニメーションはユーザー体験を向上させますが、過度な使用はパフォーマンスを低下させる可能性があります。

  • アニメーション時間は 0.2〜0.4 秒程度 が一般的
  • 同時にアニメーションする要素は 10 個以内 を目安に
  • 複雑な transform より opacity の方が軽量 です
  • will-change プロパティ の使用も検討してみましょう

これらのベストプラクティスを意識することで、滑らかで快適なアニメーションを実現できます。

Motion の exit アニメーションは、正しく理解して実装すれば、アプリケーションのユーザー体験を大きく向上させる強力なツールとなります。 この記事が、皆さんの開発の助けになれば幸いです。

関連リンク