FrontCreation Logo

TECH BLOG

Next.js + Framer Motionで発生した再レンダリング時のアニメーション問題の解決策

Next.jsのApp Router環境で、動的なページ(例:/category/[slug])をFramer Motion(motion/react)でアニメーションさせる際に、少し厄介な問題に直面しました。

具体的には、カテゴリページを初めて表示した際にはリストが期待通りにアニメーションで表示されるものの、別のカテゴリに移動すると、URLは変わるのにリストの中身が表示されなくなる(DOMには存在するが透明になる)という現象です。

この記事では、この問題が発生した原因と、それを解決するために試したアプローチ、そして最終的な解決策について詳しく解説します。

問題の概要:動的ページ間の遷移でアニメーションが再トリガーされない

問題が発生していたのは、ブログのカテゴリ別記事一覧ページです。以下のような構成になっていました。

  • app/blogs/category/[slug]/page.tsx: サーバーコンポーネント。URLのslugに基づいて記事をフィルタリングし、クライアントコンポーネントのBlogListに渡す。
  • app/blogs/BlogList.tsx: クライアントコンポーネント。受け取った記事リストをmotion.liを使ってアニメーション表示する。

最初のコードは以下のようでした。

// ...
{blogs.map((b, index) => (
  <motion.li
    key={b.slug}
    initial={{ opacity: 0, y: 30 }}
    whileInView={{ opacity: 1, y: 0 }} // 画面内に入ったらアニメーション
    transition={{ duration: 0.6, delay: index * 0.2 }}
    viewport={{ once: true }} // アニメーションは1回だけ
  >
    {/* ... */}
  </motion.li>
))}
// ...

このコードの問題点は、whileInViewviewport={{ once: true }}にありました。この設定は「要素がビューポートに初めて入った時に一度だけアニメーションを実行する」というものです。

Next.jsでは、/category/reactから/category/nextjsのように同じ系統の動的ページ間を移動する際、パフォーマンスのためにページコンポーネントを再利用することがあります。そのため、BlogListコンポーネント自体は破棄されず、新しい記事リスト(blogsプロパティ)を受け取るだけになります。結果として、コンポーネントは「既にビューポートに入っている」と判断し、2回目以降のアニメーションがトリガーされなかったのです。

試したこと①:keyプロパティの追加

Reactにコンポーネントの再生成を強制する一般的な方法は、親コンポーネントからユニークなkeyを渡すことです。page.tsxslugkeyとして渡してみました。

<BlogList key={slug} blogs={blogs} />

これによりBlogListコンポーネントはslugが変わるたびに再マウントされるようになりましたが、なぜかアニメーションの問題は完全には解決しませんでした。

最終的な解決策:AnimatePresenceの活用

Framer Motionには、まさにこのような「要素の追加・削除」をアニメーションさせるための強力な機能、AnimatePresenceが用意されています。これを使うことで、問題をエレガントに解決できました。

以下が最終的なBlogList.tsxのコードです。

'use client'
 
import Link from 'next/link'
import { motion, AnimatePresence } from 'motion/react' // AnimatePresenceをインポート
import BlogCard from '@/components/ui/BlogCard'
import { allBlogs } from 'contentlayer2/generated'
 
interface BlogListProps {
  blogs: typeof allBlogs
}
 
export default function BlogList({ blogs }: BlogListProps) {
  return (
    <ul className="flex flex-col mb-12">
      {/* AnimatePresenceでリストをラップ */}
      <AnimatePresence>
        {blogs.map((b, index) => (
          <motion.li
            key={b.slug} // keyはAnimatePresenceが要素を識別するために必須
            initial={{ opacity: 0, y: 30 }} // 初期状態
            animate={{ opacity: 1, y: 0 }}   // 表示される時の状態
            exit={{ opacity: 0, y: -30 }}    // 要素が消える時の状態
            transition={{ duration: 0.6, delay: index * 0.1 }}
          >
            <Link href={`/blogs/${b.slug}`}>
              <BlogCard title={b.title} date={b.date} categories={b.categories} />
            </Link>
          </motion.li>
        ))}
      </AnimatePresence>
    </ul>
  )
}

変更点のポイント

  1. AnimatePresenceでラップ: mapで展開されるリスト全体を<AnimatePresence>で囲みます。
  2. keyが必須: AnimatePresenceは、内部の各要素がユニークなkeyを持つことを要求します。これにより、どの要素が追加され、どの要素が削除されたのかを正確に追跡できます。
  3. whileInViewからanimate: アニメーションのトリガーを「画面内に入った時」から「コンポーネントがマウントされた時」に変更します。
  4. exitプロパティの追加: これが最も重要です。exitは、要素がツリーから削除される(=カテゴリが切り替わって古い記事リストが消える)際に実行されるアニメーションを定義します。これにより、古いリストがスムーズに消え、新しいリストがスムーズに表示されるようになります。

まとめ

Next.jsの動的ページでFramer Motionのようなアニメーションライブラリを使用する際は、コンポーネントのライフサイクルとアニメーションのトリガーを意識することが非常に重要です。

whileInViewは静的なページでのスクロールアニメーションには便利ですが、今回のようにプロパティの変更に応じてリストが動的に変わるようなケースでは、AnimatePresenceを使うのが最も確実で宣言的な解決策と言えるでしょう。