page-transition-animation について
page-transition-animationは、Next.js App Routerのページ遷移を実装・デバッグするスキルです。ナビゲーション時にAnimatePresenceの退出演出が動かない原因を解説し、FrozenRouterやView Transitions APIの方法を示します。
主な機能
- 01Next.js App Routerの遷移ガイド
- 02AnimatePresence退出演出のデバッグ
- 03FrozenRouterによるルート内容の保持
- 04View Transitions APIの方法
- 05遷移検証とアクセシビリティの指針
活用シーン
- 01App Routerのページに入場・退出演出を加える
- 02遷移時にページが即座に消える問題を調べる
- 03Framer MotionとView Transitionsを選ぶ
- 04遷移方向やクロスフェード、表示内容を確認する
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ iart-ai/ web-animation-skills --skill page-transition-animationインストール後、Claude Code で/page-transition-animationを入力して読み込まれたか確認します。
同梱ファイルREADME.mdreferences/full-examples.md
詳細
ページ遷移アニメーション(Next.js App Router)
Next.js App Router でページの入場・退出時の遷移を実装し、最もよくある問題、つまりナビゲーション時に Framer Motion の AnimatePresence の退出アニメーションがまったく実行されない問題を解決します。原因は構造にあります。App Router では、ナビゲーション時に Next.js が古いルートのコンテンツをアンマウントし、新しいルートをほぼ即座にマウントします。AnimatePresence が退出をアニメーションできるのは、永続する AnimatePresence ラッパーの下で、退出する要素がアニメーションの実行に十分な時間マウントされたままの場合だけです。ルーターがそのラッパーの下で子要素を入れ替えるため、退出アニメーションはスキップされます。この問題は、pathname をキーにした AnimatePresence と FrozenRouter パターン、または View Transitions API(ネイティブ/next-view-transitions)で解決できます。
使用する場面
Next.js App Router アプリにページ遷移アニメーションを追加するとき、ルート変更時に Framer Motion の exit prop が機能しないとき、古いページが退出アニメーションなしで瞬時に消えるとき、Next.js で Framer Motion と View Transitions API のどちらを使うか選ぶとき、またはルート間で AnimatePresence がアニメーションしない理由をデバッグするときに使用します。
退出アニメーションが実行されない理由(根本的な問題)
AnimatePresence は、直接のキー付き子要素が削除されたことを検出します。App Router のナビゲーションでは、次の点が重要です。
- ラッパーはナビゲーションをまたいで維持される必要があります。
AnimatePresenceを、それ自体がアンマウントされるコンポーネントに置くと、退出を実行するものが残りません。 AnimatePresenceが「古いものが削除され、新しいものが追加された」と認識できるように、子要素のkeyはルートごとに変わる必要があります。- 短い重なりの間、退出するサブツリーは古いコンテンツを引き続きレンダリングする必要があります。しかし、App Router はすでにルートのコンテキストを切り替えているため、そのままでは退出するツリーが新しいページのデータをレンダリングしてしまいます。これを解決するのが FrozenRouter です。
解決策 A:template.tsx + キー付き AnimatePresence + FrozenRouter
template.tsx は App Router がこの用途のために用意した仕組みです。維持される layout.tsx とは異なり、テンプレートはナビゲーションのたびに再マウントされるため、ルートごとに新しいインスタンスが作られます。ルートごとの入場アニメーションに最適です。pathname をキーにした AnimatePresence と FrozenRouter を組み合わせることで、スムーズな退出と入場を実現できます。
// app/template.tsx
'use client';
import { AnimatePresence } from 'framer-motion';
import { usePathname } from 'next/navigation';
import { FrozenRouter } from './frozen-router';
import { PageTransition } from './page-transition';
export default function Template({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
return (
<AnimatePresence mode="wait" initial={false}>
{/* key on pathname so AnimatePresence sees old removed / new added */}
<PageTransition key={pathname}>
{/* FrozenRouter keeps the OUTGOING tree rendering its old content
while it animates out */}
<FrozenRouter>{children}</FrozenRouter>
</PageTransition>
</AnimatePresence>
);
}
// app/page-transition.tsx
'use client';
import { motion } from 'framer-motion';
export function PageTransition({ children }: { children: React.ReactNode }) {
return (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
transition={{ duration: 0.3, ease: [0.22, 1, 0.36, 1] }}
>
{children}
</motion.div>
);
}
// app/frozen-router.tsx
'use client';
import { useContext, useRef } from 'react';
import {
LayoutRouterContext,
} from 'next/dist/shared/lib/app-router-context.shared-runtime';
// Freezes the router context so the exiting page keeps rendering its
// OWN content during the exit animation instead of the next route's.
export function FrozenRouter({ children }: { children: React.ReactNode }) {
const context = useContext(LayoutRouterContext ?? {});
const frozen = useRef(context).current;
return (
<LayoutRouterContext.Provider value={frozen}>
{children}
</LayoutRouterContext.Provider>
);
}
仕組みは次のとおりです。
mode="wait"を指定すると、新しいページをマウントする前に AnimatePresence が退出を完全に終了します。(入場と退出を重ねる/クロスフェードさせる場合は、デフォルトのモードを使用してください。)initial={false}を指定すると、初回読み込み時の入場アニメーションをスキップします(任意)。key={pathname}によって、AnimatePresence は各ルートを個別の presence として扱います。FrozenRouterはLayoutRouterContextのスナップショットを保持するため、退出中のサブツリーは新しいルートのコンテンツを表示するのではなく、退出が終わるまで以前のルートのコンテンツをレンダリングします。これがないと、退出がスキップされるか、誤ったコンテンツが表示されます。
注:LayoutRouterContext は Next.js の内部機能です。インポートパスは Next のバージョンによって変わる場合があります。アップグレード後にインポートでエラーが起きた場合は、このパスを更新してください。
解決策 B:ネイティブの View Transitions API
View Transitions API は、ブラウザーが遷移前後のスナップショットをキャプチャし、2 つの DOM 状態間をアニメーションします。要素ごとの退出コンポーネントは必要ありません。
/* globals.css */
@view-transition { navigation: auto; } /* opt MPA-style in (where supported) */
::view-transition-old(root) { animation: fade 0.25s both reverse; }
::view-transition-new(root) { animation: fade 0.25s both; }
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
SPA スタイルの App Router ナビゲーションでは、ルーターの更新をラップします。
if (document.startViewTransition) {
document.startViewTransition(() => router.push(href));
} else {
router.push(href);
}
next-view-transitions ライブラリ
next-view-transitions は View Transitions API を App Router のクライアントナビゲーションと統合するため、すぐに使えます。
// app/layout.tsx
import { ViewTransitions } from 'next-view-transitions';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<ViewTransitions>
<html lang="en"><body>{children}</body></html>
</ViewTransitions>
);
}
// use the library's Link instead of next/link
import { Link } from 'next-view-transitions';
<Link href="/about">About</Link>;
シンプルなページ間のフェード/モーフや共有要素の遷移を最小限の JavaScript で実現する場合は View Transitions を選びます。細かな制御や、オーケストレーション、割り込み可能なアニメーション、または段階的な退出アニメーションが必要な場合は Framer Motion を選びます。View Transitions API のブラウザー対応状況にはまだばらつきがあるため、適切なフォールバック(if (document.startViewTransition) ガード)を用意してください。
AnimatePresence のデバッグチェックリスト
exit が発火しない場合は、次の順に確認します。
- ラッパーがマウントされたままであること。
AnimatePresenceは、永続的な要素内に配置する必要があります。理想的にはtemplate.tsx、またはレイアウトレベルのクライアントコンポーネントに配置し、アンマウントされるページ内には配置しません。 - 安定していて、変更されるキーがあること。 アニメーション対象の子には、削除を検出できるように
key={pathname}(または同様のもの)が必要です。キーがない、またはキーが変化しない場合、exit は実行されません。 - motion の子孫が直接配置されていること。
exitプロパティを持つ要素はmotion.*コンポーネントであり、AnimatePresenceの子である必要があります(間に motion 以外のラッパーがあると、検出が妨げられることがあります)。 - 古いビューと新しいビューを重ねたくない場合は
mode="wait"を指定します。指定しない場合は両方が同時に描画され、切り替わることがあります。 - exit 中に遷移元のページに新しいルートのコンテンツが一瞬表示される場合は、FrozenRouter があることを確認します。
- AnimatePresence/usePathname/motion を使うすべてのファイルに
'use client'があることを確認します。 mode="wait"の下に子が一度に 1 つだけあること。 AnimatePresence は、キー付きの presence を 1 つずつ切り替えることを想定しています。
配信と検証(スタンドアロン HTML)
パッケージ化されたヘルパー(
scripts/):scripts/seek-shot.sh anim.html 0 1.5 3は?t=Nのハーネスを固定し、各時点のスクリーンショットを撮影します。scripts/contact-sheet.sh sheet.png frame-*.pngはそれらを並べ、ひと目で確認できる一覧を作成します。scripts/README.mdを参照してください。
実際の対象は Next.js アプリですが、遷移の形状(入場/退場のカーブ、方向、クロスフェード)を実証するには、ブラウザーで直接開ける HTML ファイル 1 つを用意できます。2 つのスタブ「ページ」を切り替え、CDN の motion またはネイティブの View Transitions API でアニメーションさせます。ルーターを組み込む前にモーションを検証するには、ファイル 1 つが最適な段階です。フェードを目で確かめるだけのために Next を起動する必要はありません。
出力要件:
.htmlファイル 1 つ。2 つのビューのスタブと切り替え機能を含めます。アニメーションには、CDN からmotion/React をインポートするインラインの<script type="module">、またはdocument.startViewTransitionと::view-transition-*CSS のいずれかを使います。- 解決済みの最終状態を固定する方法を用意します。ルート遷移は状態によって決まるため(どのビューか、切り替え途中か)、時計ではなく状態をスクリーンショットに撮ります。
シークハーネス — 遷移フェーズを固定する。 ?view=b で遷移先のビューをマウントします(または ?phase=exit で遷移元のツリーをアニメーション途中で保持します)。これにより、スクリーンショットを安定したフェーズで撮影できます。
<script>
const p = new URLSearchParams(location.search);
document.documentElement.dataset.view = p.get("view") || "a"; // CSS/React keys off this
// Framer Motion: set the controlled key/route from ?view; for mode="wait" the resolved end is the new page
// View Transitions: screenshot the END state (snapshots are transient); or pause via emulate slow animations
window.__ready = true;
</script>
検証ループ — 描画 → フェーズ設定 → スクリーンショット → 確認: ソース、途中、遷移先(?view=a、?phase=exit、?view=b)を開き、それぞれスクリーンショットを撮影して、忠実度(exit が実際に実行されること、方向/クロスフェードが要件に一致すること、mode="wait" による遅延がないこと)と不具合(遷移元のツリーに新しいページのコンテンツが一瞬表示される、二重マウント、FOUC、ジャンク)を確認します。ヘッドレスツールなら何でも使えます。
npx playwright screenshot --wait-for-timeout=500 "file://$PWD/transition.html?view=b" dest.png
完了前に確認すること:
- スタンドアロンで開けること — コンソールエラーがなく、CDN の
motion/React(使用する場合)が解決され、startViewTransitionがガードされていること。 ?view=/?phase=による固定で、遷移フェーズが決定論的になること。- ソース/途中/遷移先でスクリーンショットを撮影し、要件に一致すること。コンテンツのフラッシュや exit のスキップがないこと。
prefers-reduced-motionが尊重されること — スライド/移動を取り除き、短いクロスフェードにすること。- イージングが意図的に設定されていること — 入場は ease-out、退場は ease-in、duration は約 0.2~0.4s(
mode="wait"でも遅延を感じさせないこと)。
クイックリファレンス
| 必要なもの | 方法 |
|---|---|
| ルートごとの入場アニメーション | app/template.tsx(ナビゲーションごとに再マウント) |
| ナビゲーション時の退場アニメーション | キー付きの AnimatePresence mode="wait" + FrozenRouter |
| 遷移元のページに古いコンテンツを表示する | FrozenRouter(LayoutRouterContext のスナップショット) |
| ルート変更を検出する | usePathname() を key として使う |
| ページ間のシンプルなフェード/モーフ | View Transitions API / next-view-transitions |
| 共有要素の遷移 | view-transition-name CSS |
| 初回読み込み時のアニメーションをスキップする | initial={false} |
注意点
layout.tsxは保持され、ルートごとに再マウントされることはありません。ルートごとの入場アニメーションにはtemplate.tsxを使用してください。- FrozenRouter を省略すると、アニメーションの途中で退出中のページに新しいルートのコンテンツが表示される(ちらつきが発生する)か、退出アニメーションが完全にスキップされます。
LayoutRouterContextのインポートパスは Next 内部のものです。バージョン間で変更される可能性があるため、アップグレード後にまず修正が必要になる箇所です。key={pathname}がないと、AnimatePresence は同じ子要素だと認識し、退出アニメーションが一度も実行されません。mode="wait"は退出が終わるまで入場を待機します。退出に時間がかかる場合に多用すると、ナビゲーションが遅く感じられるため、再生時間を調整してください(約0.2~0.4秒)。- View Transitions API はブラウザーのサポートが十分ではありません。必ず
document.startViewTransitionをチェックしてから使用してください。 - すべてのトランジションファイルで
'use client'が必要です。サーバーコンポーネントでは AnimatePresence/usePathname を使用できません。
参考ファイル
references/full-examples.md— 方向指定/スライドトランジション、view-transition-nameを使った共有要素の View Transitions、mode="wait"を使わないクロスフェードのバリエーション、loading.tsxを使った読み込み状態のトランジション、および App Router の完全な動作例を含むフォルダー構成です。