← 文章
tutorial·2026-06-07·8 min read

Next.js App Router 页面切换动画:踩坑与最终方案

为什么 AnimatePresence 会闪、template.tsx 没效果,以及 pathname 作 key 为什么能 work

目录

给博客 main 区域加一个简单的 fade 切换动画,听起来是前端基本功。但在 Next.js App Router 里,我折腾了三种方案才搞定——中间还经历了「有内容 → 空白 → 有内容」的三连闪。

这篇文章记录完整的踩坑过程和最终方案。

目标

页面切换时,main 区域做一个最简单的淡入效果。Nav 和 Footer 保持不动,只有内容区变化。

参考 cheyuwu.com 的交互感受:不花哨,但能让路由变化有「过渡感」。

方案一:layout + AnimatePresence(失败,会闪)

最直觉的写法:在 layout.tsx 里用 framer-motion 的 AnimatePresence 包住 children,做淡出再淡入。

<AnimatePresence mode="wait">
<motion.div
key={pathname}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
{children}
</motion.div>
</AnimatePresence>

现象: 每次切换都闪一下,像「有内容了 → 没内容 → 再显示内容」。

原因: layout.tsx 里的 children 是一个共享插槽。点击导航时,Next.js 会立刻把新页面内容塞进这个插槽——不会等 exit 动画跑完。

时序是这样的:

  1. 新内容已经渲染进来了(闪一下)
  2. 旧内容的 exit 动画在跑(变空白)
  3. 新内容的 enter 动画再跑一遍(再出现)

这不是渲染太快,而是 layout + AnimatePresence 在 App Router 下架构不兼容children 的更新时机和动画的生命周期是冲突的。

方案二:template.tsx(失败,没效果)

查文档后改用 template.tsx——它会在导航时重新挂载,听起来很适合做入场动画。

app/[locale]/template.tsx
'use client'
export default function Template({ children }) {
return (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
{children}
</motion.div>
)
}

现象: 完全没动画。首页 ↔ 写作页切换,什么都看不到。

原因: template.tsx 只在自己这一层 segment 变化时才重新挂载。

我们的文件结构是 app/[locale]/template.tsx。从 / 切到 /writing 时,locale 没变(都是 zh),变的是子 segment。所以 template 不会 remount,动画根本不会触发。

Next.js 文档里的规则:

Templates remount when that segment (including its dynamic params) changes. Navigations within deeper segments do not remount higher-level templates.

要让 template 生效,得把 template 放在每次路由都会变的 segment 层级——或者接受它只管 locale 切换这种场景。

方案三:layout + pathname 作 key 淡入(成功)

回到 layout,但换一个思路:不做 exit 动画,只做 fade-in。用 key={pathname}motion.div 在路由变化时整个重新挂载。

'use client'
import { usePathname } from '@/i18n/routing'
import { motion } from 'framer-motion'
const isFirst = { current: true }
export default function PageTransition({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
const skipInitial = isFirst.current
useEffect(() => {
if (isFirst.current) isFirst.current = false
}, [])
return (
<motion.div
key={pathname}
initial={skipInitial ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, ease: 'easeOut' }}
>
{children}
</motion.div>
)
}

layout.tsx 里:

<main className="flex-1">
<PageTransition>{children}</PageTransition>
</main>

效果: 旧页瞬间消失,新页 300ms 淡入。干净,不闪。

为什么这个能 work?

要点作用
key={pathname}路由一变,React 销毁旧的 motion.div、创建新的,触发 initial → animate
不用 AnimatePresence不和 children 抢更新时机,避免三连闪
initial={false} 跳过首次第一次打开页面不播动画
usePathname 来自 @/i18n/routing不带 locale 前缀,/writing 就是 /writing

核心逻辑:不和 App Router 的 children 更新机制对抗,顺着它来——新页面进来时,用一个全新的容器做淡入就好。

如果要做双向动画呢?

淡入 only 是目前 App Router 下最稳的方案。如果还想要淡出 + 淡入,选项有:

  • React 19 的 <ViewTransition> + Next.js 16 的 experimental.viewTransition(浏览器原生 View Transitions API)
  • 接受 trade-off:旧页瞬间消失,新页淡入(大多数博客够用了)

我在这个博客里选了后者。简单、零闪烁、好维护。

总结

方案结果原因
layout + AnimatePresence三连闪children 立刻更新,exit/enter 时序冲突
[locale]/template.tsx无效果同 locale 内导航不触发 remount
layout + key={pathname} 淡入正常新容器挂载时做 fade-in,不对抗框架

App Router 的页面切换动画,关键不是选哪个动画库,而是理解 layout / template / children 三者的生命周期。搞清楚了,十几行代码就够用。