关于 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转场效果验证与无障碍建议
适用场景
- 01为 App Router 页面添加进入和退出动画
- 02排查导航时页面直接消失的问题
- 03选择 Framer 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 设置 key 的 AnimatePresence 和 FrozenRouter 模式,或使用 View Transitions API(原生/next-view-transitions)。
适用场景
在 Next.js App Router 应用中添加页面过渡动画、Framer Motion 的 exit 属性在路由变更时不起作用、旧页面消失得太快而没有退出动画、为 Next.js 选择 Framer Motion 还是 View Transitions API,或排查 AnimatePresence 为什么无法在路由之间执行动画时使用。
为什么退出动画不会触发(核心问题)
AnimatePresence 会检测直接子元素中带 key 的元素是否被移除。在 App Router 导航时:
- 包装器必须在导航过程中保持存在。如果
AnimatePresence位于一个会自行卸载的组件中,就没有东西可以继续执行退出动画。 - 子元素的
key必须随路由变化,这样AnimatePresence才能识别出“旧元素已移除,新元素已添加”。 - 在短暂的重叠期间,即将退出的子树必须继续渲染旧内容;但 App Router 已经替换了路由上下文,因此即将退出的树本来会渲染新页面的数据。FrozenRouter 正是为了解决这个问题。
方案 A:template.tsx + 带 key 的 AnimatePresence + FrozenRouter
template.tsx 是 App Router 为此专门提供的钩子:与会持久存在的 layout.tsx 不同,template 会在每次导航时重新挂载,为每个路由创建一个新实例,非常适合为每个路由添加进入动画。将其与按 pathname 设置 key 的 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 将每个路由视为一个独立的存在状态。FrozenRouter会快照LayoutRouterContext,因此即将退出的子树会在整个退出动画期间继续渲染上一个路由的内容,而不会闪现新路由的内容。没有它,退出动画要么会被跳过,要么会显示错误的内容。
注意:LayoutRouterContext 是 Next.js 内部 API;其导入路径可能会随 Next 版本变化。升级后如果导入失败,需要更新的就是这个路径。
方案 B:原生 View Transitions API
View Transitions API 会由浏览器捕获前后快照,在两个 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>;
如果只需要简单的跨页面淡入淡出/形变和共享元素过渡,且希望尽量减少 JS,请选择 View Transitions;如果需要精细编排、可中断或交错执行的退出动画,请选择 Framer Motion。View Transitions API 的浏览器支持仍不一致,因此请提供平滑降级方案(if (document.startViewTransition) 守卫)。
AnimatePresence 调试清单
如果退出动画仍未触发,请按顺序检查:
- 包装器保持挂载。
AnimatePresence必须位于持久存在的组件中——最好是template.tsx,或布局级客户端组件——而不是即将卸载的页面内部。 - 稳定且会变化的键。 动画子元素需要
key={pathname}(或类似的键),以便检测到移除。没有键,或键不会变化 → 不会触发退出动画。 - 直接的 motion 后代。 带有
exit属性的元素必须是motion.*组件,并且是AnimatePresence的子元素(中间插入非 motion 包装器可能会阻碍检测)。 - 如果不希望新旧内容重叠,请使用
mode="wait";否则两者会同时渲染,可能导致跳动。 - 如果退出页面在退出期间闪现新路由的内容,请使用 FrozenRouter。
- 所有使用 AnimatePresence/usePathname/motion 的文件都要添加
'use client'。 - 在
mode="wait"下,一次只能有一个子元素——AnimatePresence 需要在单个带键的 presence 之间切换。
交付并验证(独立 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 文件——其中包含两个切换显示的桩页面,并通过 CDN 上的 motion 或原生 View Transitions API 添加动画。在接入路由器之前,用一个文件验证动画是恰当的层级;不要只为目测淡入淡出效果而搭建 Next。
输出约定:
- 一个
.html文件:包含两个视图桩和一个切换控件,通过内联<script type="module">从 CDN 导入motion/React,或使用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),分别截图,并检查保真度(退出动画确实运行,方向/交叉淡化符合要求,没有 mode="wait" 延迟),以及异常表现(退出中的树闪现新页面内容、重复挂载、FOUC、卡顿)。任何无头工具都可以:
npx playwright screenshot --wait-for-timeout=500 "file://$PWD/transition.html?view=b" dest.png
完成前:
- 可独立打开——没有控制台错误,CDN 上的
motion/React(如果使用)可正常解析,且对startViewTransition做了保护。 ?view=/?phase=冻结到确定的过渡阶段。- 已对源视图/中间阶段/目标视图截图——符合要求,没有内容闪现或跳过退出动画。
- 尊重
prefers-reduced-motion——将滑动/平移效果替换为较短的交叉淡化。 - 缓动设计合理——进入采用缓出,退出采用缓入,时长约为 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.4s)。- View Transitions API 尚未获得完整的浏览器支持;务必检查
document.startViewTransition是否可用。 - 每个过渡文件都需要
'use client';服务端组件无法使用 AnimatePresence/usePathname。
参考文件
references/full-examples.md— 定向/滑动过渡、使用view-transition-name的共享元素 View Transitions、非mode="wait"的交叉淡入淡出变体、使用loading.tsx的加载状态过渡,以及完整可运行的 App Router 文件夹布局。