Files
novalon-website/src/components/ui/page-transition.tsx
T
zhangxiangand阿睿 04e91e3dbf refactor(animations): converge P2 stagger delays to ≤60ms target
Task #16 P2: converge 25 stagger/delay delays to ≤60ms target.

scope: 12 files, 25 staggerDelay/delayChildren props
before: 0.07/0.08/0.1/0.12s -> after: 0.05s (50ms, aligns ScrollReveal default)

verified:
- tsc --noEmit: clean (TSC_EXIT=0)
- eslint (12 files): 0 errors, 22 pre-existing warnings only
- jest: 130/130 suites, 1573 passed, 2 skipped, 0 failed (JEST_EXIT=0)
- motion audit: P0=0, P1=5 preserved, P2=0, OK=425

note: P1 residual 5 preserved (flip-clock 2 + page-transition 2 by
semantics; design-system.ts 1 dead-code deferred). visual regression
waived: stagger is entrance delay, reducedMotion yields same pixels.

Co-Authored-By: 阿睿 <workbuddy@local>
2026-09-03 22:41:10 +08:00

121 lines
2.5 KiB
TypeScript

'use client';
import { motion, AnimatePresence } from 'framer-motion';
import { usePathname } from 'next/navigation';
import { useEffect, useState } from 'react';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
const pageVariants = {
initial: {
opacity: 0,
y: 8,
scale: 0.98,
},
animate: {
opacity: 1,
y: 0,
scale: 1,
transition: {
duration: 0.4,
ease: [0.25, 1, 0.5, 1] as const,
staggerChildren: 0.05,
},
},
exit: {
opacity: 0,
y: -8,
scale: 0.98,
transition: {
duration: 0.2,
ease: [0.22, 1, 0.36, 1] as const,
},
},
};
export function PageTransition({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const shouldReduceMotion = useReducedMotion();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
// SSR 和首次 hydration 阶段直接渲染子内容,避免 framer-motion DOM 差异导致 hydration mismatch
if (!mounted || shouldReduceMotion) {
return <>{children}</>;
}
return (
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={pathname}
variants={pageVariants}
initial="initial"
animate="animate"
exit="exit"
style={{
willChange: 'transform, opacity',
backfaceVisibility: 'hidden',
}}
>
{children}
</motion.div>
</AnimatePresence>
);
}
const sectionVariants = {
hidden: { opacity: 0, y: 20 },
visible: (i: number) => ({
opacity: 1,
y: 0,
transition: {
delay: i * 0.08,
duration: 0.5,
ease: [0.25, 1, 0.5, 1] as const,
},
}),
};
export function StaggerChildren({
children,
className = '',
staggerCount = 4
}: {
children: React.ReactNode;
className?: string;
staggerCount?: number;
}) {
const shouldReduceMotion = useReducedMotion();
if (shouldReduceMotion) {
return <div className={className}>{children}</div>;
}
return (
<motion.div
className={className}
initial="hidden"
animate="visible"
variants={{
visible: {
transition: {
staggerChildren: 0.06,
delayChildren: 0.05,
},
},
}}
>
{Array.isArray(children)
? children.map((child, i) => (
<motion.div key={i} custom={Math.min(i, staggerCount)} variants={sectionVariants}>
{child}
</motion.div>
))
: children
}
</motion.div>
);
}