page-transition-animation
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChinesePage Transition Animation (Next.js App Router)
页面过渡动画(Next.js App Router)
Implement page enter/exit transitions in the Next.js App Router, and fix the single most common failure: Framer Motion exit animations never fire on navigation. The reason is structural — in the App Router, when navigating, Next.js unmounts the old route's content and mounts the new one almost immediately. can only animate an exit if the exiting element stays mounted under a persistent wrapper long enough to run the animation. Because the router swaps children out from under it, the exit is skipped. Two approaches solve this: a pathname-keyed with the FrozenRouter pattern, or the View Transitions API (native / ).
AnimatePresenceAnimatePresenceAnimatePresenceAnimatePresencenext-view-transitions在Next.js App Router中实现页面进出过渡效果,并解决最常见的问题:Framer Motion 退出动画在导航时始终不触发。问题根源在于结构设计——在App Router中导航时,Next.js几乎会立即卸载旧路由内容并挂载新内容。只有在退出元素在持久化的包裹器下保持挂载状态足够长的时间时,才能执行退出动画。由于路由器会快速替换其子元素,退出动画会被跳过。有两种解决方案:基于路径名做键的搭配FrozenRouter模式,或者使用View Transitions API(原生实现 / 库)。
AnimatePresenceAnimatePresenceAnimatePresenceAnimatePresencenext-view-transitionsWhen to use
适用场景
Use when adding animated page transitions in a Next.js App Router app, when a Framer Motion prop does nothing on route change, when an old page disappears instantly instead of animating out, when choosing between Framer Motion and the View Transitions API for Next.js, or when debugging why won't animate between routes.
exitAnimatePresence适用于在Next.js App Router项目中添加页面过渡动画、Framer Motion 属性在路由切换时无效果、旧页面直接消失而非动画退出、在Next.js中选择Framer Motion还是View Transitions API,以及调试无法在路由间执行动画的场景。
exitAnimatePresenceWhy exit doesn't fire (the core problem)
退出动画不触发的核心原因
AnimatePresence- The wrapper must persist across the navigation. If lives in a component that itself unmounts, there is nothing left to run the exit.
AnimatePresence - The child's must change per route so AnimatePresence sees "old removed, new added".
key - During the brief overlap, the outgoing subtree must still render its old content — but the App Router has already swapped the route context, so the outgoing tree would otherwise render the new page's data. This is what FrozenRouter fixes.
AnimatePresence- 包裹器必须在导航过程中保持挂载。如果所在的组件本身被卸载,就没有执行退出动画的载体了。
AnimatePresence - 子元素的必须随路由变化,这样AnimatePresence才能识别“旧元素被移除、新元素被添加”。
key - 在短暂的重叠阶段,即将退出的子树必须仍渲染旧内容——但App Router已经切换了路由上下文,否则退出的子树会渲染新页面的数据。这正是FrozenRouter要解决的问题。
Solution A: template.tsx + keyed AnimatePresence + FrozenRouter
方案A:template.tsx + 带键AnimatePresence + FrozenRouter
template.tsxlayout.tsxAnimatePresencetsx
// 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>
);
}tsx
// 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>
);
}tsx
// 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>
);
}How it fits together:
- makes AnimatePresence fully finish the exit before mounting the new page. (Use the default mode if enter/exit should overlap/crossfade.)
mode="wait" - skips the enter animation on first load (optional).
initial={false} - is what makes AnimatePresence treat each route as a distinct presence.
key={pathname} - snapshots
FrozenRouterso the outgoing subtree renders the previous route's content throughout the exit, instead of flashing the new route's content. Without it, exit either skips or shows the wrong content.LayoutRouterContext
Note: is a Next.js internal; its import path can change between Next versions. If the import breaks after an upgrade, that path is the thing to update.
LayoutRouterContexttemplate.tsxlayout.tsxAnimatePresencetsx
// 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}>
{/* 以pathname作为key,让AnimatePresence识别旧元素移除/新元素添加 */}
<PageTransition key={pathname}>
{/* FrozenRouter让即将退出的页面在动画过程中保持渲染旧内容 */}
<FrozenRouter>{children}</FrozenRouter>
</PageTransition>
</AnimatePresence>
);
}tsx
// 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>
);
}tsx
// app/frozen-router.tsx
'use client';
import { useContext, useRef } from 'react';
import {
LayoutRouterContext,
} from 'next/dist/shared/lib/app-router-context.shared-runtime';
// 冻结路由上下文,让退出页面在动画过程中保持渲染自己的内容,而非下一个路由的内容。
export function FrozenRouter({ children }: { children: React.ReactNode }) {
const context = useContext(LayoutRouterContext ?? {});
const frozen = useRef(context).current;
return (
<LayoutRouterContext.Provider value={frozen}>
{children}
</LayoutRouterContext.Provider>
);
}各部分协作逻辑:
- 让AnimatePresence在完成退出动画后再挂载新页面。(如果需要进入/退出动画重叠/淡入淡出,可使用默认模式。)
mode="wait" - 跳过首次加载时的进入动画(可选配置)。
initial={false} - 是让AnimatePresence将每个路由视为独立元素的关键。
key={pathname} - 会快照
FrozenRouter,让退出的子树在整个动画过程中渲染之前路由的内容,避免闪现新路由内容。如果没有它,退出动画要么被跳过,要么显示错误内容。LayoutRouterContext
注意:是Next.js内部API;其导入路径可能随Next版本变化。如果升级后导入失效,需要更新该路径。
LayoutRouterContextSolution B: native View Transitions API
方案B:原生View Transitions API
The View Transitions API animates between two DOM states with the browser capturing before/after snapshots — no per-element exit components.
css
/* 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; } }For SPA-style App Router navigations, wrap the router update:
ts
if (document.startViewTransition) {
document.startViewTransition(() => router.push(href));
} else {
router.push(href);
}View Transitions API通过浏览器捕获前后DOM状态的快照来实现两个状态间的动画——无需为每个元素设置退出组件。
css
/* globals.css */
@view-transition { navigation: auto; } /* 在支持的浏览器中启用类MPA的过渡 */
::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导航,需要包裹路由更新逻辑:
ts
if (document.startViewTransition) {
document.startViewTransition(() => router.push(href));
} else {
router.push(href);
}next-view-transitions library
next-view-transitions库
next-view-transitionstsx
// 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>
);
}tsx
// use the library's Link instead of next/link
import { Link } from 'next-view-transitions';
<Link href="/about">About</Link>;Choose View Transitions for simple cross-page fades/morphs and shared-element transitions with minimal JS; choose Framer Motion when fine-grained, orchestrated, interruptible, or staggered exit animations are needed. View Transitions API browser support is still uneven, so provide a graceful fallback (the guard).
if (document.startViewTransition)next-view-transitionstsx
// 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>
);
}tsx
// 使用库提供的Link替代next/link
import { Link } from 'next-view-transitions';
<Link href="/about">About</Link>;如果需要简单的跨页面淡入淡出/变形效果,以及共享元素过渡且希望JS代码量最少,选择View Transitions;如果需要细粒度、可编排、可中断或 staggered(交错)的退出动画,选择Framer Motion。View Transitions API的浏览器支持仍不完善,因此需要提供优雅降级方案(即判断)。
if (document.startViewTransition)AnimatePresence debug checklist
AnimatePresence调试清单
When exit still won't fire, verify in order:
- Wrapper stays mounted. must live in something persistent — ideally
AnimatePresence, or a layout-level client component — not inside the page that's unmounting.template.tsx - Stable, changing key. The animated child needs (or similar) so removal is detected. No key, or a key that doesn't change → no exit.
key={pathname} - Direct motion descendant. The element with the prop must be a
exitcomponent and a child ofmotion.*(a non-motion wrapper in between can block detection).AnimatePresence - if old and new should not overlap; without it both render simultaneously and may jump.
mode="wait" - FrozenRouter present if the outgoing page flashes the new route's content during exit.
- on every file using AnimatePresence/usePathname/motion.
'use client' - Single child at a time under — AnimatePresence expects one keyed presence to swap.
mode="wait"
如果退出动画仍不触发,请按顺序验证以下内容:
- 包裹器保持挂载。必须位于持久化的组件中——理想位置是
AnimatePresence或布局级客户端组件——不能在即将卸载的页面内部。template.tsx - 稳定且变化的key。动画子元素需要(或类似配置)才能被检测到移除。没有key或key不变化→无退出动画。
key={pathname} - 直接的motion后代。带有属性的元素必须是
exit组件,且是motion.*的直接子元素(中间的非motion包裹器可能会阻断检测)。AnimatePresence - 配置。如果不希望新旧页面重叠,需要设置该属性;否则两者会同时渲染,可能导致页面跳动。
mode="wait" - 存在FrozenRouter。如果退出页面在动画过程中闪现新路由内容,需要添加FrozenRouter。
- 添加。所有使用AnimatePresence/usePathname/motion的文件都需要添加该指令。
'use client' - 下每次仅一个子元素。AnimatePresence在该模式下期望只有一个带键元素被替换。
mode="wait"
Deliver & verify (standalone HTML)
交付与验证(独立HTML文件)
Packaged helper ():scripts/freezes thescripts/seek-shot.sh anim.html 0 1.5 3harness and screenshots each moment;?t=Ntiles them for one-glance review. Seescripts/contact-sheet.sh sheet.png frame-*.png.scripts/README.md
The real target is a Next.js app, but to prove the transition shape (the enter/exit curve, direction, crossfade) you can ship one HTML file that opens directly in a browser — two stub "pages" you toggle, animated with from CDN or the native View Transitions API. One file is the right tier for validating motion before wiring the router; don't stand up Next just to eyeball a fade.
motionOutput contract:
- One file: two view stubs and a toggle, animated with either an inline
.htmlimporting<script type="module">/React from CDN, ormotion+document.startViewTransitionCSS.::view-transition-* - A way to freeze the resolved end state — a route transition is state-driven (which view, mid-swap), so screenshot the state, not a clock.
Seek harness — pin a transition phase. mounts the destination view (or to hold the outgoing tree mid-animation) so a screenshot lands on a settled phase:
?view=b?phase=exithtml
<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>Verify loop — render → set phase → screenshot → check: open source, mid, and destination (, , ), screenshot each, and check fidelity (exit actually runs, direction/crossfade matches the brief, no lag) plus artifacts (outgoing tree flashing the new page's content, double-mount, FOUC, jank). Any headless tool works:
?view=a?phase=exit?view=bmode="wait"bash
npx playwright screenshot --wait-for-timeout=500 "file://$PWD/transition.html?view=b" dest.pngBefore you finish:
- Opens standalone — no console errors, CDN /React (if used) resolves,
motionguarded.startViewTransition - The /
?view=freeze lands a deterministic transition phase.?phase= - Screenshotted at source / mid / destination — matches the brief, no content flash or skipped exit.
- honored — slide/translate dropped to a short crossfade.
prefers-reduced-motion - Easing is intentional — entrances ease-out, exits ease-in, durations ~0.2–0.4s (not laggy under ).
mode="wait"
打包工具(目录):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文件——包含两个模拟“页面”和切换按钮,使用CDN的或原生View Transitions API实现动画。在接入路由器之前,用单个文件验证动画效果是合适的方案;无需搭建Next环境仅为了查看淡入淡出效果。
motion输出规范:
- 一个文件:包含两个视图模拟组件、一个切换按钮,通过内联
.html从CDN导入<script type="module">/React实现动画,或使用motion+document.startViewTransitionCSS实现。::view-transition-* - 一种冻结最终状态的方式——路由过渡是状态驱动的(当前视图、切换中状态),因此需要截图状态而非时间点。
测试工具——固定过渡阶段。会挂载目标视图(或在动画过程中保持退出的子树),以便截图捕捉到稳定的阶段:
?view=b?phase=exithtml
<script>
const p = new URLSearchParams(location.search);
document.documentElement.dataset.view = p.get("view") || "a"; // CSS/React基于此值做判断
// Framer Motion:从?view设置受控key/路由;对于mode="wait",最终状态是新页面
// View Transitions:截图最终状态(快照是临时的);或通过模拟慢速动画暂停
window.__ready = true;
</script>验证流程——渲染→设置阶段→截图→检查: 打开初始、中间、目标状态(、、),分别截图并检查保真度(退出动画确实执行、方向/淡入淡出符合预期、下无延迟)以及异常情况(退出页面闪现新页面内容、双重挂载、FOUC(无样式内容闪烁)、卡顿)。任何无头工具都可以实现:
?view=a?phase=exit?view=bmode="wait"bash
npx playwright screenshot --wait-for-timeout=500 "file://$PWD/transition.html?view=b" dest.png完成前检查:
- 可独立打开——无控制台错误,CDN的/React(如果使用)可正常加载,
motion已做兼容判断。startViewTransition - /
?view=参数可固定确定的过渡阶段。?phase= - 初始/中间/目标状态的截图符合预期,无内容闪现或退出动画被跳过的情况。
- 遵循设置——滑动/平移动画降级为短时长淡入淡出。
prefers-reduced-motion - 缓动效果符合设计意图——进入动画使用ease-out,退出动画使用ease-in,时长约0.2–0.4秒(下不会显得卡顿)。
mode="wait"
Quick reference
快速参考
| Need | Approach |
|---|---|
| Per-route enter animation | |
| Exit animation on nav | keyed |
| Outgoing page shows old content | FrozenRouter (snapshot |
| Detect route change | |
| Simple cross-page fade/morph | View Transitions API / next-view-transitions |
| Shared element transition | |
| Skip first-load animation | |
| 需求 | 实现方案 |
|---|---|
| 路由进入动画 | |
| 导航时的退出动画 | 带键 |
| 退出页面保持旧内容 | FrozenRouter(快照 |
| 检测路由变化 | 使用 |
| 简单跨页面淡入淡出/变形 | View Transitions API / next-view-transitions |
| 共享元素过渡 | |
| 跳过首次加载动画 | |
Gotchas
注意事项
- persists and will NOT remount per route — use
layout.tsxfor per-route enter animations.template.tsx - Omitting FrozenRouter makes the exiting page render the new route's content mid-animation (visible flash) or skip exit entirely.
- import path is a Next internal; it may change across versions — first thing to fix after an upgrade.
LayoutRouterContext - Without , AnimatePresence sees the same child and never triggers exit.
key={pathname} - waits for exit before enter; overusing it on slow exits makes navigation feel laggy — tune durations (~0.2-0.4s).
mode="wait" - View Transitions API lacks full browser support; always guard .
document.startViewTransition - Every transition file needs ; server components can't use AnimatePresence/usePathname.
'use client'
- 是持久化的,不会随路由重新挂载——如需路由进入动画,请使用
layout.tsx。template.tsx - 省略FrozenRouter会导致退出页面在动画过程中渲染新路由内容(可见闪现)或直接跳过退出动画。
- 的导入路径是Next.js内部API,可能随版本变化——升级后首先检查该路径是否需要更新。
LayoutRouterContext - 没有的话,AnimatePresence会认为是同一个子元素,永远不会触发退出动画。
key={pathname} - 会等待退出动画完成后再执行进入动画;如果退出动画过慢,过度使用会让导航感觉卡顿——请调整时长(约0.2-0.4秒)。
mode="wait" - View Transitions API的浏览器支持不完整;始终要对做兼容判断。
document.startViewTransition - 所有涉及过渡的文件都需要添加;服务端组件无法使用AnimatePresence/usePathname。
'use client'
Reference files
参考文件
- — directional/slide transitions, shared-element View Transitions with
references/full-examples.md, a non-view-transition-namecrossfade variant, loading-state transitions withmode="wait", and a complete working App Router folder layout.loading.tsx
- —— 方向/滑动过渡、使用
references/full-examples.md的共享元素View Transitions、非view-transition-name的淡入淡出变体、结合mode="wait"的加载状态过渡,以及完整可运行的App Router文件夹结构。loading.tsx