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>
121 lines
2.5 KiB
TypeScript
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>
|
|
);
|
|
}
|