React, Vue.js
投稿日:

Next.js App Router × Framer Motion のページ遷移アニメーションを AI 駆動で実装する ─ layout.tsx の罠と template.tsx の正解

株式会社ファストコーディングのフルスタックエンジニア MrFireです。

先日、久しぶりに好きなバンドのライブに行ってきました。ステージの照明演出が見事で、曲の切り替わりに合わせて照明がフェードアウトし、次の曲のイントロとともにじわっと新しい色が立ち上がる。あの「場面転換」の心地よさは、Webサイトのページ遷移にも通じるものがあるなと、帰りのバイクの上でぼんやり考えていました。

ライブの余韻に浸りながら、頭の中ではもう実装方法を考えていた自分がちょっと悲しい。

最近の案件で、まさにそのページ遷移の実装に向き合う機会がありました。あるブランドサイトのリニューアル案件で、デザイナーから「ページ間の切り替わりに、ふわっとしたフェードとスライドを入れてほしい」というオーダーが入ったのです。

以前の Pages Router の時代であれば、遷移アニメーションの実装パターンはある程度確立されていました。しかし App Router ではルーティングの仕組みが根本的に変わっていて、同じアプローチが通用しません。ここをAI駆動開発でどう切り抜けたのか、実際のやり取りを交えてお伝えします。

なぜ App Router でページ遷移が難しいのか

まず前提を整理します。

Next.js の App Router では、layout.tsx がルート間で共有され、再マウントされない設計になっています。ページを遷移しても layout.tsx のコンポーネントツリーは維持されたまま、page.tsx の部分だけが差し替わります。

これはパフォーマンス上は大きなメリットです。ヘッダーやサイドバーなど共通UIの再描画を避けられます。しかし「ページ全体のフェードイン・フェードアウト」を実装しようとすると、問題が出てきます。

Framer Motion の AnimatePresence は、コンポーネントのマウント/アンマウントを検知してアニメーションを発火させます。ところが App Router の layout.tsx は再マウントされないため、AnimatePresence のトリガーが効きません。

ここが Pages Router 時代との最大の違いです。

AI に「App Router でページ遷移アニメーション」を聞いてみた

まず Claude に以下のように依頼しました。

「Next.js 14 の App Router で、Framer Motion を使ってページ遷移時にフェードイン・フェードアウトのアニメーションを実装したい。layout.tsx に AnimatePresence を配置する方法を教えてほしい。」

AI が返してきたコードは、一見それらしいものでした。

Bad Case: AI が最初に提案した layout.tsx ベースの実装

// app/layout.tsx(AIの初期提案 ─ これは動かない)
'use client';

import { AnimatePresence, motion } from 'framer-motion';
import { usePathname } from 'next/navigation';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const pathname = usePathname();

  return (
    <html lang="ja">
      <body>
        <AnimatePresence mode="wait">
          <motion.div
            key={pathname}
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={{ duration: 0.3 }}
          >
            {children}
          </motion.div>
        </AnimatePresence>
      </body>
    </html>
  );
}

このコードの問題点は3つあります。

  • layout.tsx に 'use client' を付けると、配下のすべてのコンポーネントがクライアントコンポーネントになる。Server Components のメリットが完全に失われる
  • layout.tsx は遷移時に再マウントされないため、key={pathname} を付けても AnimatePresence の exit アニメーションが正しく発火しない
  • <html> と <body> タグを含むルートレイアウトをクライアントコンポーネントにすると、ハイドレーションの不整合が起きやすくなる

実際にこのコードを動かしてみると、ページ遷移時にフェードアウトが一切発生せず、新しいページがぱっと表示されるだけでした。

正解は template.tsx にある

ここで私は AI に追加の文脈を与えました。

「layout.tsx は遷移時に再マウントされないが、template.tsx は遷移のたびに再マウントされる。template.tsx を使ったアプローチに切り替えてほしい。」

この指示が正解でした。

Next.js の App Router には layout.tsx と template.tsx という2つの仕組みがあります。公式ドキュメントによると、template.tsx は layout.tsx と似た役割を持ちますが、ナビゲーションのたびに新しいインスタンスが作成される点が異なります。

つまり、template.tsx であれば AnimatePresence のマウント/アンマウント検知が正しく機能するのです。

Good Case: template.tsx + PageTransition コンポーネント

まず、アニメーションのロジックを担うラッパーコンポーネントを作ります。

// components/PageTransition.tsx
'use client';

import { motion } from 'framer-motion';

const variants = {
  hidden: {
    opacity: 0,
    y: 20,
  },
  enter: {
    opacity: 1,
    y: 0,
  },
  exit: {
    opacity: 0,
    y: -20,
  },
};

export default function PageTransition({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <motion.div
      variants={variants}
      initial="hidden"
      animate="enter"
      exit="exit"
      transition={{
        duration: 0.3,
        ease: 'easeInOut',
      }}
    >
      {children}
    </motion.div>
  );
}

次に、template.tsx で AnimatePresence と PageTransition を組み合わせます。

// app/template.tsx
'use client';

import { AnimatePresence } from 'framer-motion';
import { usePathname } from 'next/navigation';
import PageTransition from '@/components/PageTransition';

export default function Template({
  children,
}: {
  children: React.ReactNode;
}) {
  const pathname = usePathname();

  return (
    <AnimatePresence mode="wait">
      <PageTransition key={pathname}>
        {children}
      </PageTransition>
    </AnimatePresence>
  );
}

ポイントは key={pathname} の位置です。AnimatePresence の直下にある PageTransition に key を渡すことで、パスが変わるたびにコンポーネントの再マウントが発生し、フェードインのアニメーションが実行されます。

そして layout.tsx はサーバーコンポーネントのまま維持できます。

// app/layout.tsx(サーバーコンポーネントのまま)
import type { Metadata } from 'next';

export const metadata: Metadata = {
  title: 'My Site',
  description: 'サイトの説明',
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ja">
      <body>
        <header>
          <nav>{/* ナビゲーション */}</nav>
        </header>
        <main>{children}</main>
      </body>
    </html>
  );
}

layout.tsx から 'use client' を外せたことで、Server Components の恩恵をそのまま受けられます。ヘッダーやナビゲーションは遷移のたびに再レンダリングされず、アニメーションが走るのは main の中身だけです。

ルートごとにアニメーションを変える

基本のフェードが動くようになったら、次にデザイナーから「ページの階層に応じてスライド方向を変えたい」というリクエストが来ました。トップから下層ページへ進むときは右にスライド、下層ページからトップに戻るときは左にスライドする、という演出です。

この要件は、遷移方向を管理する仕組みを加えることで実現できます。

// hooks/useTransitionDirection.ts
'use client';

import { useRef, useEffect } from 'react';
import { usePathname } from 'next/navigation';

function getDepth(pathname: string): number {
  return pathname.split('/').filter(Boolean).length;
}

export function useTransitionDirection(): 'forward' | 'back' {
  const pathname = usePathname();
  const prevDepth = useRef(getDepth(pathname));
  const direction = useRef<'forward' | 'back'>('forward');

  useEffect(() => {
    const currentDepth = getDepth(pathname);
    direction.current = currentDepth >= prevDepth.current ? 'forward' : 'back';
    prevDepth.current = currentDepth;
  }, [pathname]);

  return direction.current;
}

このカスタムフックでは、URLの階層の深さ(スラッシュの数)を比較して、遷移が「進む」のか「戻る」のかを判定しています。

PageTransition コンポーネントを拡張して、方向に応じたバリアントを適用します。

// components/DirectionalTransition.tsx
'use client';

import { motion } from 'framer-motion';
import { useTransitionDirection } from '@/hooks/useTransitionDirection';

const slideVariants = {
  forward: {
    hidden: { opacity: 0, x: 100 },
    enter: { opacity: 1, x: 0 },
    exit: { opacity: 0, x: -100 },
  },
  back: {
    hidden: { opacity: 0, x: -100 },
    enter: { opacity: 1, x: 0 },
    exit: { opacity: 0, x: 100 },
  },
};

export default function DirectionalTransition({
  children,
}: {
  children: React.ReactNode;
}) {
  const direction = useTransitionDirection();
  const variants = slideVariants[direction];

  return (
    <motion.div
      variants={variants}
      initial="hidden"
      animate="enter"
      exit="exit"
      transition={{
        duration: 0.3,
        ease: [0.25, 0.1, 0.25, 1],
      }}
    >
      {children}
    </motion.div>
  );
}

template.tsx の PageTransition を DirectionalTransition に差し替えるだけで、ルートの深度に応じたスライド方向が適用されます。

パフォーマンスで気をつけたこと

ページ遷移アニメーションはユーザー体験を向上させますが、実装次第ではパフォーマンスに悪影響を与えます。今回の案件で注意した点を整理します。

GPU レイヤーの活用

Framer Motion はデフォルトで transform と opacity を使ってアニメーションを実行します。これらのプロパティは CSS の合成(Compositing)レイヤーで処理されるため、メインスレッドをブロックしません。

一方、width や height、top や left をアニメーションさせると、レイアウトの再計算(Reflow)が発生します。今回のコードでは意図的に x(translateX)と opacity だけを使い、レイアウト変更を避けています。

will-change の適切な使用

motion.div にはデフォルトで will-change: transform が付与されます。これはブラウザにアニメーションの予告を伝えるプロパティです。ただし、アニメーション対象外の要素に will-change を残すとメモリを無駄に消費します。

Framer Motion はアニメーション完了後に will-change を自動的に解除するため、この点は特別な対応は不要でした。ライブラリ側で適切に処理されていることを確認してから、チームに「大丈夫です」と報告しました。

transition の duration は 300ms が目安

「どのくらいの速さがいいですか」とデザイナーに聞かれたとき、私は300msを提案しました。Google の Material Design ガイドラインでは、画面全体の遷移アニメーションに 250ms〜350ms を推奨しています。200ms だとユーザーが認知する前に終わってしまい、500ms だと「待たされている」と感じるラインです。

実際に300msで実装してデザイナーに見せたところ、一発でOKが出ました。

AI が苦手だった部分と人間の判断

今回の実装で AI の力を借りましたが、AI には苦手な部分もありました。

まず、layout.tsx と template.tsx の使い分けです。AI に最初に聞いたとき、layout.tsx ベースの実装を返してきました。これは「ページ遷移アニメーションを実装したい」という要件だけでは、App Router 固有の制約(layout は再マウントされない)をAIが十分に考慮できなかったためです。

開発者側が「template.tsx は遷移のたびに再マウントされる」という知識を与えたことで、AI は正しい実装パターンに切り替えることができました。AI駆動開発では、フレームワーク固有の設計思想を理解した上で、AIに正しい文脈を与えることが重要です。

次に、パフォーマンスの判断です。AI は動くコードを書くのは得意ですが、「このアニメーションはGPUレイヤーで処理されるから安全」「この duration は人間の認知に合っている」といった経験則に基づく判断は、まだ人間のほうが的確です。

AIを使いこなすコツは、「何をAIに任せて、何を自分で判断するか」を切り分けることです。コンポーネントの設計やバリアントの定義はAIに任せ、アーキテクチャの選択やパフォーマンスの判断は人間が行う。この役割分担がうまくいった案件でした。

導入結果

このアプローチでブランドサイトのページ遷移を実装した結果を整理します。

  • デザイナーの反応: 初回のプレゼンで「まさにこれが欲しかった」とOK。修正なしで本番適用
  • パフォーマンス: Lighthouse のパフォーマンススコアに影響なし。遷移アニメーション中のフレームレートは 60fps を維持
  • Server Components との共存: layout.tsx をサーバーコンポーネントのまま維持できたため、初回ロードのバンドルサイズ増加は Framer Motion 本体(約 30KB gzip)のみ

特に3点目は重要でした。Bad Case のように layout.tsx を丸ごとクライアントコンポーネントにしてしまうと、配下のすべてのコンポーネントがクライアントバンドルに含まれます。template.tsx に分離したことで、影響範囲を最小限に抑えられました。

まとめ

今回は、Next.js App Router で Framer Motion のページ遷移アニメーションを AI 駆動で実装したプロセスを紹介しました。

App Router のルーティング設計では、layout.tsx が再マウントされないという特性がアニメーション実装の壁になります。この壁を越えるためのポイントを整理します。

  • layout.tsx ではなく template.tsx を使う。template.tsx はナビゲーションのたびに再マウントされるため、AnimatePresence のトリガーが正しく機能する
  • アニメーションロジックは PageTransition のようなラッパーコンポーネントに切り出す。key={pathname} を渡してパス変更を検知させる
  • アニメーション対象のプロパティは transform と opacity に限定し、GPU合成レイヤーで処理されるようにする

AI駆動開発のポイントとしては、AIにフレームワーク固有の制約を伝えることが精度の高い回答を引き出す鍵でした。「layout.tsx は再マウントされない」という一文を追加するだけで、AI の提案は Bad Case から Good Case に切り替わりました。

株式会社ファストコーディングでは、React/Next.jsのフロントエンド実装やAI駆動開発のご相談を承っています。ページ遷移アニメーションに限らず、App Router への移行やパフォーマンス最適化でお困りの際は、お問い合わせフォームからお気軽にご連絡ください。