page-transition-animation

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Page 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
AnimatePresence
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.
AnimatePresence
can only animate an exit if the exiting element stays mounted under a persistent
AnimatePresence
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
AnimatePresence
with the FrozenRouter pattern, or the View Transitions API (native /
next-view-transitions
).
在Next.js App Router中实现页面进出过渡效果,并解决最常见的问题:Framer Motion
AnimatePresence
退出动画在导航时始终不触发
。问题根源在于结构设计——在App Router中导航时,Next.js几乎会立即卸载旧路由内容并挂载新内容。
AnimatePresence
只有在退出元素在持久化的
AnimatePresence
包裹器下保持挂载状态足够长的时间
时,才能执行退出动画。由于路由器会快速替换其子元素,退出动画会被跳过。有两种解决方案:基于路径名做键的
AnimatePresence
搭配FrozenRouter模式,或者使用View Transitions API(原生实现 /
next-view-transitions
库)。

When to use

适用场景

Use when adding animated page transitions in a Next.js App Router app, when a Framer Motion
exit
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
AnimatePresence
won't animate between routes.
适用于在Next.js App Router项目中添加页面过渡动画、Framer Motion
exit
属性在路由切换时无效果、旧页面直接消失而非动画退出、在Next.js中选择Framer Motion还是View Transitions API,以及调试
AnimatePresence
无法在路由间执行动画的场景。

Why exit doesn't fire (the core problem)

退出动画不触发的核心原因

AnimatePresence
detects removal of a direct keyed child. On App Router navigation:
  1. The wrapper must persist across the navigation. If
    AnimatePresence
    lives in a component that itself unmounts, there is nothing left to run the exit.
  2. The child's
    key
    must change per route so AnimatePresence sees "old removed, new added".
  3. 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
会检测直接带键子元素的移除。在App Router导航时:
  1. 包裹器必须在导航过程中保持挂载。如果
    AnimatePresence
    所在的组件本身被卸载,就没有执行退出动画的载体了。
  2. 子元素的
    key
    必须随路由变化,这样AnimatePresence才能识别“旧元素被移除、新元素被添加”。
  3. 在短暂的重叠阶段,即将退出的子树必须仍渲染旧内容——但App Router已经切换了路由上下文,否则退出的子树会渲染新页面的数据。这正是FrozenRouter要解决的问题。

Solution A: template.tsx + keyed AnimatePresence + FrozenRouter

方案A:template.tsx + 带键AnimatePresence + FrozenRouter

template.tsx
is the App Router's purpose-built hook for this: unlike
layout.tsx
(which persists), a template remounts on every navigation, giving each route a fresh instance — ideal for per-route enter animations. Combine it with a pathname-keyed
AnimatePresence
and FrozenRouter for clean exit + enter.
tsx
// 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:
  • mode="wait"
    makes AnimatePresence fully finish the exit before mounting the new page. (Use the default mode if enter/exit should overlap/crossfade.)
  • initial={false}
    skips the enter animation on first load (optional).
  • key={pathname}
    is what makes AnimatePresence treat each route as a distinct presence.
  • FrozenRouter
    snapshots
    LayoutRouterContext
    so 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.
Note:
LayoutRouterContext
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.
template.tsx
是App Router为此场景量身设计的钩子:与
layout.tsx
(持久化存在)不同,template会在每次导航时重新挂载,为每个路由提供全新实例——非常适合路由进入动画。将其与基于路径名做键的
AnimatePresence
和FrozenRouter结合,即可实现流畅的退出+进入动画。
tsx
// 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>
  );
}
各部分协作逻辑:
  • mode="wait"
    让AnimatePresence在完成退出动画后再挂载新页面。(如果需要进入/退出动画重叠/淡入淡出,可使用默认模式。)
  • initial={false}
    跳过首次加载时的进入动画(可选配置)。
  • key={pathname}
    是让AnimatePresence将每个路由视为独立元素的关键。
  • FrozenRouter
    会快照
    LayoutRouterContext
    ,让退出的子树在整个动画过程中渲染之前路由的内容,避免闪现新路由内容。如果没有它,退出动画要么被跳过,要么显示错误内容。
注意:
LayoutRouterContext
是Next.js内部API;其导入路径可能随Next版本变化。如果升级后导入失效,需要更新该路径。

Solution 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-transitions
integrates the View Transitions API with the App Router's client navigation so it works out of the box:
tsx
// 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
if (document.startViewTransition)
guard).
next-view-transitions
将View Transitions API与App Router的客户端导航集成,开箱即可使用:
tsx
// 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:
  1. Wrapper stays mounted.
    AnimatePresence
    must live in something persistent — ideally
    template.tsx
    , or a layout-level client component — not inside the page that's unmounting.
  2. Stable, changing key. The animated child needs
    key={pathname}
    (or similar) so removal is detected. No key, or a key that doesn't change → no exit.
  3. Direct motion descendant. The element with the
    exit
    prop must be a
    motion.*
    component and a child of
    AnimatePresence
    (a non-motion wrapper in between can block detection).
  4. mode="wait"
    if old and new should not overlap; without it both render simultaneously and may jump.
  5. FrozenRouter present if the outgoing page flashes the new route's content during exit.
  6. 'use client'
    on every file using AnimatePresence/usePathname/motion.
  7. Single child at a time under
    mode="wait"
    — AnimatePresence expects one keyed presence to swap.
如果退出动画仍不触发,请按顺序验证以下内容:
  1. 包裹器保持挂载
    AnimatePresence
    必须位于持久化的组件中——理想位置是
    template.tsx
    或布局级客户端组件——不能在即将卸载的页面内部。
  2. 稳定且变化的key。动画子元素需要
    key={pathname}
    (或类似配置)才能被检测到移除。没有key或key不变化→无退出动画。
  3. 直接的motion后代。带有
    exit
    属性的元素必须是
    motion.*
    组件,且是
    AnimatePresence
    的直接子元素(中间的非motion包裹器可能会阻断检测)。
  4. mode="wait"
    配置
    。如果不希望新旧页面重叠,需要设置该属性;否则两者会同时渲染,可能导致页面跳动。
  5. 存在FrozenRouter。如果退出页面在动画过程中闪现新路由内容,需要添加FrozenRouter。
  6. 添加
    'use client'
    。所有使用AnimatePresence/usePathname/motion的文件都需要添加该指令。
  7. mode="wait"
    下每次仅一个子元素
    。AnimatePresence在该模式下期望只有一个带键元素被替换。

Deliver & verify (standalone HTML)

交付与验证(独立HTML文件)

Packaged helper (
scripts/
):
scripts/seek-shot.sh anim.html 0 1.5 3
freezes the
?t=N
harness and screenshots each moment;
scripts/contact-sheet.sh sheet.png frame-*.png
tiles them for one-glance review. See
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
motion
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.
Output contract:
  • One
    .html
    file: two view stubs and a toggle, animated with either an inline
    <script type="module">
    importing
    motion
    /React from CDN, or
    document.startViewTransition
    +
    ::view-transition-*
    CSS.
  • 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.
?view=b
mounts the destination view (or
?phase=exit
to hold the outgoing tree mid-animation) so a screenshot lands on a settled phase:
html
<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 (
?view=a
,
?phase=exit
,
?view=b
), screenshot each, and check fidelity (exit actually runs, direction/crossfade matches the brief, no
mode="wait"
lag) plus artifacts (outgoing tree flashing the new page's content, double-mount, FOUC, jank). Any headless tool works:
bash
npx playwright screenshot --wait-for-timeout=500 "file://$PWD/transition.html?view=b" dest.png
Before you finish:
  1. Opens standalone — no console errors, CDN
    motion
    /React (if used) resolves,
    startViewTransition
    guarded.
  2. The
    ?view=
    /
    ?phase=
    freeze lands a deterministic transition phase.
  3. Screenshotted at source / mid / destination — matches the brief, no content flash or skipped exit.
  4. prefers-reduced-motion
    honored — slide/translate dropped to a short crossfade.
  5. 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的
motion
或原生View Transitions API实现动画。在接入路由器之前,用单个文件验证动画效果是合适的方案;无需搭建Next环境仅为了查看淡入淡出效果。
输出规范:
  • 一个
    .html
    文件:包含两个视图模拟组件、一个切换按钮,通过内联
    <script type="module">
    从CDN导入
    motion
    /React实现动画,或使用
    document.startViewTransition
    +
    ::view-transition-*
    CSS实现。
  • 一种冻结最终状态的方式——路由过渡是状态驱动的(当前视图、切换中状态),因此需要截图状态而非时间点。
测试工具——固定过渡阶段
?view=b
会挂载目标视图(或
?phase=exit
在动画过程中保持退出的子树),以便截图捕捉到稳定的阶段:
html
<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>
验证流程——渲染→设置阶段→截图→检查: 打开初始、中间、目标状态(
?view=a
?phase=exit
?view=b
),分别截图并检查保真度(退出动画确实执行、方向/淡入淡出符合预期、
mode="wait"
下无延迟)以及异常情况(退出页面闪现新页面内容、双重挂载、FOUC(无样式内容闪烁)、卡顿)。任何无头工具都可以实现:
bash
npx playwright screenshot --wait-for-timeout=500 "file://$PWD/transition.html?view=b" dest.png
完成前检查:
  1. 可独立打开——无控制台错误,CDN的
    motion
    /React(如果使用)可正常加载,
    startViewTransition
    已做兼容判断。
  2. ?view=
    /
    ?phase=
    参数可固定确定的过渡阶段。
  3. 初始/中间/目标状态的截图符合预期,无内容闪现或退出动画被跳过的情况。
  4. 遵循
    prefers-reduced-motion
    设置——滑动/平移动画降级为短时长淡入淡出。
  5. 缓动效果符合设计意图——进入动画使用ease-out,退出动画使用ease-in,时长约0.2–0.4秒(
    mode="wait"
    下不会显得卡顿)。

Quick reference

快速参考

NeedApproach
Per-route enter animation
app/template.tsx
(remounts each nav)
Exit animation on navkeyed
AnimatePresence mode="wait"
+ FrozenRouter
Outgoing page shows old contentFrozenRouter (snapshot
LayoutRouterContext
)
Detect route change
usePathname()
as the
key
Simple cross-page fade/morphView Transitions API / next-view-transitions
Shared element transition
view-transition-name
CSS
Skip first-load animation
initial={false}
需求实现方案
路由进入动画
app/template.tsx
(每次导航重新挂载)
导航时的退出动画带键
AnimatePresence mode="wait"
+ FrozenRouter
退出页面保持旧内容FrozenRouter(快照
LayoutRouterContext
检测路由变化使用
usePathname()
作为
key
简单跨页面淡入淡出/变形View Transitions API / next-view-transitions
共享元素过渡
view-transition-name
CSS属性
跳过首次加载动画
initial={false}

Gotchas

注意事项

  • layout.tsx
    persists and will NOT remount per route — use
    template.tsx
    for per-route enter animations.
  • Omitting FrozenRouter makes the exiting page render the new route's content mid-animation (visible flash) or skip exit entirely.
  • LayoutRouterContext
    import path is a Next internal; it may change across versions — first thing to fix after an upgrade.
  • Without
    key={pathname}
    , AnimatePresence sees the same child and never triggers exit.
  • mode="wait"
    waits for exit before enter; overusing it on slow exits makes navigation feel laggy — tune durations (~0.2-0.4s).
  • View Transitions API lacks full browser support; always guard
    document.startViewTransition
    .
  • Every transition file needs
    'use client'
    ; server components can't use AnimatePresence/usePathname.
  • layout.tsx
    是持久化的,不会随路由重新挂载——如需路由进入动画,请使用
    template.tsx
  • 省略FrozenRouter会导致退出页面在动画过程中渲染路由内容(可见闪现)或直接跳过退出动画。
  • LayoutRouterContext
    的导入路径是Next.js内部API,可能随版本变化——升级后首先检查该路径是否需要更新。
  • 没有
    key={pathname}
    的话,AnimatePresence会认为是同一个子元素,永远不会触发退出动画。
  • mode="wait"
    会等待退出动画完成后再执行进入动画;如果退出动画过慢,过度使用会让导航感觉卡顿——请调整时长(约0.2-0.4秒)。
  • View Transitions API的浏览器支持不完整;始终要对
    document.startViewTransition
    做兼容判断。
  • 所有涉及过渡的文件都需要添加
    'use client'
    ;服务端组件无法使用AnimatePresence/usePathname。

Reference files

参考文件

  • references/full-examples.md
    — directional/slide transitions, shared-element View Transitions with
    view-transition-name
    , a non-
    mode="wait"
    crossfade variant, loading-state transitions with
    loading.tsx
    , and a complete working App Router folder layout.
  • references/full-examples.md
    —— 方向/滑动过渡、使用
    view-transition-name
    的共享元素View Transitions、非
    mode="wait"
    的淡入淡出变体、结合
    loading.tsx
    的加载状态过渡,以及完整可运行的App Router文件夹结构。