在Next.js/React中实现带URL更新的无缝页面转场动画
解决Next.js页面转场动画(指定元素更新+URL同步)
核心思路
要解决旧内容立即消失的问题,关键是延迟卸载旧页面组件直到转场动画完成;同时利用Next.js的路由API配合framer-motion的动画生命周期,实现URL同步更新的同时保留指定元素的转场效果。
实现步骤(App Router 为例,Pages Router 可适配)
1. 自定义导航组件替代默认Link
避免直接使用Next.js的Link,手动控制导航流程,先触发动画再更新URL:
'use client'; import { useRouter } from 'next/navigation'; import { useState } from 'react'; export function CustomLink({ href, children }: { href: string; children: React.ReactNode }) { const router = useRouter(); const [isTransitioning, setIsTransitioning] = useState(false); const handleNavigate = async (e: React.MouseEvent) => { e.preventDefault(); setIsTransitioning(true); // 等待旧内容退出动画完成(时长需和framer-motion配置一致) await new Promise(resolve => setTimeout(resolve, 500)); // 更新URL并导航 router.push(href); setTimeout(() => setIsTransitioning(false), 100); }; return ( <a href={href} onClick={handleNavigate} className={isTransitioning ? 'transitioning' : ''}> {children} </a> ); }
2. 根布局中用AnimatePresence管理转场
在app/layout.tsx中包裹页面内容,通过AnimatePresence控制新旧页面的存在状态,指定需要动画的区域:
'use client'; import { AnimatePresence, motion } from 'framer-motion'; import { usePathname } from 'next/navigation'; export default function RootLayout({ children }: { children: React.ReactNode }) { const pathname = usePathname(); return ( <html lang="zh-CN"> <body> {/* 固定元素(如导航栏)放在AnimatePresence外,不参与转场 */} <header className="fixed-header">...</header> <AnimatePresence mode="wait"> {/* 用pathname作为key,路径变化时触发动画 */} <motion.div key={pathname} initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -20 }} transition={{ duration: 0.5 }} style={{ position: 'absolute', width: '100%', top: '60px' }} > {children} </motion.div> </AnimatePresence> </body> </html> ); }
3. 指定局部元素转场(非整页)
如果仅需特定元素动画,可在页面组件中拆分结构,让固定内容脱离转场流程:
'use client'; import { motion, AnimatePresence } from 'framer-motion'; import { usePathname } from 'next/navigation'; export default function ProductPage() { const pathname = usePathname(); return ( <div className="page-container"> {/* 固定侧边栏,不参与转场 */} <aside className="fixed-sidebar">产品分类</aside> {/* 仅动态内容区域执行转场 */} <AnimatePresence mode="wait"> <motion.div key={pathname} initial={{ scale: 0.95, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} exit={{ scale: 0.95, opacity: 0 }} transition={{ duration: 0.4 }} > <h1>产品详情</h1> <p>仅此区域会有转场动画</p> </motion.div> </AnimatePresence> </div> ); }
4. 适配浏览器前进/后退
监听popstate事件,同步触发对应方向的转场动画:
'use client'; import { useEffect, useState } from 'react'; import { usePathname } from 'next/navigation'; import { AnimatePresence, motion } from 'framer-motion'; export default function RootLayout({ children }: { children: React.ReactNode }) { const pathname = usePathname(); const [isBackNavigation, setIsBackNavigation] = useState(false); useEffect(() => { const handlePopState = () => { setIsBackNavigation(true); setTimeout(() => setIsBackNavigation(false), 500); }; window.addEventListener('popstate', handlePopState); return () => window.removeEventListener('popstate', handlePopState); }, []); return ( <html lang="zh-CN"> <body> <AnimatePresence mode="wait"> <motion.div key={pathname} initial={isBackNavigation ? { opacity: 0, x: -20 } : { opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={isBackNavigation ? { opacity: 0, x: 20 } : { opacity: 0, x: -20 }} transition={{ duration: 0.5 }} style={{ position: 'absolute', width: '100%' }} > {children} </motion.div> </AnimatePresence> </body> </html> ); }
关键注意点
AnimatePresence的mode="wait":确保旧组件退出动画完成后再卸载,避免内容瞬间消失。- 绝对定位布局:新旧页面组件重叠,防止转场时出现布局跳动。
- 动画时长同步:自定义导航中的
setTimeout时长需和framer-motion的transition.duration保持一致。 - Pages Router 适配:用
useRouter的push方法,根组件改为_app.tsx包裹AnimatePresence,逻辑与App Router一致。
内容的提问来源于stack exchange,提问作者Tom
相关产品推荐
相关产品推荐

