推荐阅读
'use client'
import { motion, AnimatePresence } from 'framer-motion'
import { usePathname } from 'next/navigation'
const defaultProps = {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
transition: { ease: 'easeInOut', duration: 0.5 },
}
export function PageTransitionEffect({
children,
}: { children: React.ReactNode }) {
const pathname = usePathname()
return (
<AnimatePresence mode='popLayout'>
<motion.main {...defaultProps} key={pathname}>
{children}
</motion.main>
</AnimatePresence>
)
}
然而这样是不行的,旧页面在执行卸载动画期间,内容会变成新页面的内容。
我们需要确保,旧页面的内容在执行卸载动画期间,内容不变。
此时就需要用到 LayoutRouterContext
:
import { LayoutRouterContext } from 'next/dist/shared/lib/app-router-context.shared-runtime'
import { useContext, useRef } from 'react'
function FrozenRouter(props: { children: React.ReactNode }) {
const context = useContext(LayoutRouterContext ?? {})
const frozen = useRef(context).current
if (!frozen) {
return <>{props.children}</>
}
return (
<LayoutRouterContext.Provider value={frozen}>
{props.children}
</LayoutRouterContext.Provider>
)
}
用这个 FrozenRouter
包裹一下上面关键代码中的 children
就好了。
以防我们在什么地方需要访问当前动画是否已完成,所以添加一个全局钩子
// usePageTransitionEffect.ts
import { create } from 'zustand'
export const usePageTransitionEffect = create(() => ({
exited: true,
}))
接下来是完整代码:
// PageTransitionEffect.tsx
'use client'
import { usePageTransitionEffect } from './usePageTransitionEffect'
import { motion, AnimatePresence } from 'framer-motion'
import { usePathname } from 'next/navigation'
import { LayoutRouterContext } from 'next/dist/shared/lib/app-router-context.shared-runtime'
import { useContext, useEffect, useRef } from 'react'
import type { MotionProps } from 'framer-motion'
function FrozenRouter(props: { children: React.ReactNode }) {
const context = useContext(LayoutRouterContext ?? {})
const frozen = useRef(context).current
if (!frozen) {
return <>{props.children}</>
}
return (
<LayoutRouterContext.Provider value={frozen}>
{props.children}
</LayoutRouterContext.Provider>
)
}
export type PageTransitionEffectConfig = Omit<MotionProps, 'children' | 'key'>
export type PageTransitionEffectProps = PageTransitionEffectConfig & {
children: React.ReactNode
}
const defaultProps = {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
transition: { ease: 'easeInOut', duration: 0.5 },
} satisfies PageTransitionEffectConfig
const onEnter = () => {
usePageTransitionEffect.setState({
exited: false,
})
}
const onExitComplete = () => {
usePageTransitionEffect.setState({
exited: true,
})
}
export function PageTransitionEffect({
children,
...props
}: PageTransitionEffectProps) {
const pathname = usePathname()
useEffect(() => {
onEnter()
}, [pathname])
return (
<AnimatePresence mode='popLayout' onExitComplete={onExitComplete}>
<motion.main {...defaultProps} {...props} key={pathname}>
<FrozenRouter>{children}</FrozenRouter>
</motion.main>
</AnimatePresence>
)
}
需要动画的地方用 PageTransitionEffect
包裹一下就可以了。
// layout.tsx
export default function CustomLayout({ children }) {
return <>
<YourHeader />
{/* 此时,页面切换时,只有 content 有动画,header 和 footer 没有动画 */}
<PageTransitionEffect style={{ width: '100%', maxWidth: 1024, margin: '0 auto' }}>
{children}
</PageTransitionEffect>
<YourFooter />
</>
}
// children component
export function ChildrenComponent() {
const { exited } = usePageTransitionEffect()
// ...
}
2025-07-06 更新
这样存在一个问题,页面首次加载的时候会有动画(opacity 从 0 到 1),我认为,页面首次加载无需入场动画,只需要在前端路由的时候有动画就好了。
大致思路是,页面首次加载时,把 entry transition duration 强制设为 0,然后在 animation duration 时间过后,把 entry transition duration 的限制移除,那么之后的前端路由就有动画了。
代码比较复杂,详细代码见 github,伪代码如下:
const defaultProps = {
initial: { opacity: 0, transition: { duration: 0.5 } },
animate: { opacity: 1, transition: { duration: 0.5 } },
exit: { opacity: 0, transition: { duration: 0.5 } },
} satisfies LimitedAnimationOptions
const hardReloadAddonProps: LimitedAnimationOptions = {
initial: { transition: { duration: 0 } },
animate: { transition: { duration: 0 } },
}
function PageTransitionEffect() {
const hasHydrated = useHasHydrated()
const animProps = merge({}, defaultProps, {
initial,
animate,
exit,
})
// 使用 useRef 来避免每次渲染都重新计算 duration
const animAnimateDuration = useRef(
animProps.animate?.transition?.duration ??
defaultProps.animate.transition.duration
).current
const [appliedAnimProps, setAppliedAnimProps] = useState(
merge({}, animProps, hardReloadAddonProps)
)
// 在 animAnimateDuration 改变时,
// 动画时间结束后,把 entry transition duration 的限制移除
useListen(animAnimateDuration, () => {
setTimeout(() => {
setAppliedAnimProps(animProps)
}, animAnimateDuration * 1000)
})
if (!hasHydrated) {
// 初始加载时,不要动画,避免 opacity: 0 初始样式
return <main {...divProps}>{children}</main>
}
return (
<AnimatePresence ...>
<motion.main {...appliedAnimProps} ...>
<FrozenRouter>{children}</FrozenRouter>
</motion.main>
</AnimatePresence>
)
}
如非特别声明,本站作品均为原创,遵循【自由转载-保持署名-非商用-非衍生 创意共享 3.0 许可证】。
对于转载作品,如需二次转载,请遵循原作许可。