Appearance
页面过渡动画
概述
页面过渡动画是提升用户体验的关键要素之一。Framer Motion提供了强大的页面过渡能力,可以轻松实现平滑的路由切换效果。本文将全面讲解如何使用Framer Motion创建各种页面过渡动画,包括与React Router、Next.js等路由库的集成方案。
基础页面过渡
AnimatePresence配置
tsx
import { AnimatePresence, motion } from 'framer-motion';
import { Routes, Route, useLocation } from 'react-router-dom';
function App() {
const location = useLocation();
return (
<AnimatePresence mode="wait">
<Routes location={location} key={location.pathname}>
<Route path="/" element={<HomePage />} />
<Route path="/about" element={<AboutPage />} />
<Route path="/contact" element={<ContactPage />} />
</Routes>
</AnimatePresence>
);
}简单淡入淡出
tsx
const pageVariants = {
initial: {
opacity: 0,
},
animate: {
opacity: 1,
transition: {
duration: 0.3,
},
},
exit: {
opacity: 0,
transition: {
duration: 0.3,
},
},
};
function HomePage() {
return (
<motion.div
variants={pageVariants}
initial="initial"
animate="animate"
exit="exit"
>
<h1>Home Page</h1>
</motion.div>
);
}滑动过渡
水平滑动
tsx
const slideVariants = {
enter: (direction: number) => ({
x: direction > 0 ? 1000 : -1000,
opacity: 0,
}),
center: {
zIndex: 1,
x: 0,
opacity: 1,
},
exit: (direction: number) => ({
zIndex: 0,
x: direction < 0 ? 1000 : -1000,
opacity: 0,
}),
};
function SlideTransition() {
const [[page, direction], setPage] = useState([0, 0]);
const paginate = (newDirection: number) => {
setPage([page + newDirection, newDirection]);
};
return (
<div className="page-container">
<AnimatePresence initial={false} custom={direction}>
<motion.div
key={page}
custom={direction}
variants={slideVariants}
initial="enter"
animate="center"
exit="exit"
transition={{
x: { type: "spring", stiffness: 300, damping: 30 },
opacity: { duration: 0.2 },
}}
>
Page {page}
</motion.div>
</AnimatePresence>
<button onClick={() => paginate(-1)}>Previous</button>
<button onClick={() => paginate(1)}>Next</button>
</div>
);
}垂直滑动
tsx
const verticalSlideVariants = {
enter: {
y: '100%',
opacity: 0,
},
center: {
y: 0,
opacity: 1,
},
exit: {
y: '-100%',
opacity: 0,
},
};
function VerticalSlide() {
return (
<motion.div
variants={verticalSlideVariants}
initial="enter"
animate="center"
exit="exit"
transition={{ duration: 0.5, ease: [0.48, 0.15, 0.25, 0.96] }}
>
Vertical Content
</motion.div>
);
}缩放过渡
放大进入
tsx
const scaleVariants = {
initial: {
scale: 0.8,
opacity: 0,
},
animate: {
scale: 1,
opacity: 1,
transition: {
type: "spring",
stiffness: 200,
damping: 20,
},
},
exit: {
scale: 1.2,
opacity: 0,
transition: {
duration: 0.2,
},
},
};
function ScalePage() {
return (
<motion.div
variants={scaleVariants}
initial="initial"
animate="animate"
exit="exit"
>
<h1>Scaled Content</h1>
</motion.div>
);
}组合缩放和旋转
tsx
const scaleRotateVariants = {
initial: {
scale: 0,
rotate: -180,
opacity: 0,
},
animate: {
scale: 1,
rotate: 0,
opacity: 1,
transition: {
type: "spring",
stiffness: 260,
damping: 20,
},
},
exit: {
scale: 0,
rotate: 180,
opacity: 0,
transition: {
duration: 0.3,
},
},
};方向感知过渡
基于路由方向
tsx
function DirectionalRouter() {
const location = useLocation();
const [direction, setDirection] = useState<'forward' | 'backward'>('forward');
const prevLocationRef = useRef(location);
useEffect(() => {
const routes = ['/', '/about', '/services', '/contact'];
const prevIndex = routes.indexOf(prevLocationRef.current.pathname);
const currentIndex = routes.indexOf(location.pathname);
setDirection(currentIndex > prevIndex ? 'forward' : 'backward');
prevLocationRef.current = location;
}, [location]);
const pageVariants = {
enter: (dir: string) => ({
x: dir === 'forward' ? 1000 : -1000,
opacity: 0,
}),
center: {
x: 0,
opacity: 1,
},
exit: (dir: string) => ({
x: dir === 'forward' ? -1000 : 1000,
opacity: 0,
}),
};
return (
<AnimatePresence custom={direction} mode="wait">
<motion.div
key={location.pathname}
custom={direction}
variants={pageVariants}
initial="enter"
animate="center"
exit="exit"
transition={{ duration: 0.3 }}
>
<Routes location={location}>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/services" element={<Services />} />
<Route path="/contact" element={<Contact />} />
</Routes>
</motion.div>
</AnimatePresence>
);
}基于手势方向
tsx
import { useGesture } from '@use-gesture/react';
function GestureRouter() {
const [page, setPage] = useState(0);
const [direction, setDirection] = useState(0);
const bind = useGesture({
onDrag: ({ movement: [mx], direction: [xDir], cancel }) => {
if (Math.abs(mx) > 100) {
setDirection(xDir);
setPage((p) => p + (xDir > 0 ? -1 : 1));
cancel();
}
},
});
const pageVariants = {
enter: (dir: number) => ({
x: dir > 0 ? 1000 : -1000,
opacity: 0,
}),
center: {
x: 0,
opacity: 1,
},
exit: (dir: number) => ({
x: dir < 0 ? 1000 : -1000,
opacity: 0,
}),
};
return (
<div {...bind()} className="gesture-container">
<AnimatePresence custom={direction} mode="wait">
<motion.div
key={page}
custom={direction}
variants={pageVariants}
initial="enter"
animate="center"
exit="exit"
>
Page {page}
</motion.div>
</AnimatePresence>
</div>
);
}复杂过渡效果
分层动画
tsx
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
delayChildren: 0.3,
},
},
exit: {
opacity: 0,
transition: {
staggerChildren: 0.05,
staggerDirection: -1,
},
},
};
const itemVariants = {
hidden: { y: 20, opacity: 0 },
visible: {
y: 0,
opacity: 1,
transition: {
type: "spring",
stiffness: 100,
},
},
exit: { y: -20, opacity: 0 },
};
function LayeredPage() {
return (
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
exit="exit"
>
<motion.header variants={itemVariants}>
<h1>Page Title</h1>
</motion.header>
<motion.main variants={itemVariants}>
<p>Main content</p>
</motion.main>
<motion.aside variants={itemVariants}>
<p>Sidebar</p>
</motion.aside>
<motion.footer variants={itemVariants}>
<p>Footer</p>
</motion.footer>
</motion.div>
);
}遮罩过渡
tsx
function MaskTransition() {
return (
<>
<motion.div
initial={{ scaleY: 0 }}
animate={{ scaleY: 0 }}
exit={{ scaleY: 1 }}
transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: '#000',
transformOrigin: 'top',
zIndex: 999,
}}
/>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3, delay: 0.3 }}
>
<h1>Page Content</h1>
</motion.div>
</>
);
}3D翻转过渡
tsx
const flip3DVariants = {
initial: {
rotateY: 90,
opacity: 0,
},
animate: {
rotateY: 0,
opacity: 1,
transition: {
duration: 0.6,
ease: [0.48, 0.15, 0.25, 0.96],
},
},
exit: {
rotateY: -90,
opacity: 0,
transition: {
duration: 0.6,
ease: [0.48, 0.15, 0.25, 0.96],
},
},
};
function Flip3DPage() {
return (
<motion.div
variants={flip3DVariants}
initial="initial"
animate="animate"
exit="exit"
style={{
transformStyle: 'preserve-3d',
perspective: 1000,
}}
>
<h1>3D Flipped Content</h1>
</motion.div>
);
}Next.js集成
App Router过渡
tsx
// app/template.tsx
'use client';
import { motion } from 'framer-motion';
export default function Template({ children }: { children: React.ReactNode }) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
>
{children}
</motion.div>
);
}Pages Router过渡
tsx
// pages/_app.tsx
import { AnimatePresence } from 'framer-motion';
import type { AppProps } from 'next/app';
export default function App({ Component, pageProps, router }: AppProps) {
return (
<AnimatePresence mode="wait" initial={false}>
<Component {...pageProps} key={router.asPath} />
</AnimatePresence>
);
}
// pages/index.tsx
import { motion } from 'framer-motion';
export default function Home() {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<h1>Home Page</h1>
</motion.div>
);
}路由加载状态
tsx
'use client';
import { usePathname, useSearchParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
export function RouteLoader() {
const pathname = usePathname();
const searchParams = useSearchParams();
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
setIsLoading(true);
const timeout = setTimeout(() => setIsLoading(false), 500);
return () => clearTimeout(timeout);
}, [pathname, searchParams]);
return (
<AnimatePresence>
{isLoading && (
<motion.div
initial={{ scaleX: 0 }}
animate={{ scaleX: 1 }}
exit={{ scaleX: 0 }}
transition={{ duration: 0.3, ease: "easeInOut" }}
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
height: 3,
backgroundColor: '#3b82f6',
transformOrigin: 'left',
zIndex: 9999,
}}
/>
)}
</AnimatePresence>
);
}高级技巧
共享元素过渡
tsx
function SharedElementLayout() {
const [selectedId, setSelectedId] = useState<string | null>(null);
return (
<>
<div className="grid">
{items.map((item) => (
<motion.div
key={item.id}
layoutId={item.id}
onClick={() => setSelectedId(item.id)}
className="card"
>
<motion.h2>{item.title}</motion.h2>
</motion.div>
))}
</div>
<AnimatePresence>
{selectedId && (
<motion.div
layoutId={selectedId}
onClick={() => setSelectedId(null)}
className="expanded-card"
>
<motion.h2>{items.find(i => i.id === selectedId)?.title}</motion.h2>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<p>Detailed content...</p>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</>
);
}视差滚动过渡
tsx
import { useScroll, useTransform } from 'framer-motion';
function ParallaxPage() {
const { scrollY } = useScroll();
const y1 = useTransform(scrollY, [0, 300], [0, -150]);
const y2 = useTransform(scrollY, [0, 300], [0, -75]);
const opacity = useTransform(scrollY, [0, 200, 300], [1, 0.5, 0]);
return (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
<motion.div style={{ y: y1, opacity }} className="parallax-bg" />
<motion.div style={{ y: y2 }} className="parallax-content">
<h1>Parallax Content</h1>
</motion.div>
</motion.div>
);
}路径动画过渡
tsx
const pathVariants = {
hidden: {
pathLength: 0,
opacity: 0,
},
visible: {
pathLength: 1,
opacity: 1,
transition: {
pathLength: {
type: "spring",
duration: 1.5,
bounce: 0,
},
opacity: { duration: 0.01 },
},
},
};
function SVGPathTransition() {
return (
<motion.svg
width="600"
height="600"
viewBox="0 0 600 600"
initial="hidden"
animate="visible"
>
<motion.circle
cx="100"
cy="100"
r="80"
stroke="#00cc88"
variants={pathVariants}
/>
<motion.line
x1="200"
y1="100"
x2="400"
y2="100"
stroke="#0099ff"
variants={pathVariants}
/>
<motion.rect
width="160"
height="160"
x="220"
y="220"
rx="20"
stroke="#ff0055"
variants={pathVariants}
/>
</motion.svg>
);
}实战案例
1. 电商产品详情
tsx
function ProductGallery() {
const [selectedImage, setSelectedImage] = useState(0);
const images = ['/img1.jpg', '/img2.jpg', '/img3.jpg'];
return (
<div className="product-gallery">
<AnimatePresence mode="wait">
<motion.img
key={selectedImage}
src={images[selectedImage]}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.3 }}
/>
</AnimatePresence>
<div className="thumbnails">
{images.map((img, index) => (
<motion.button
key={index}
onClick={() => setSelectedImage(index)}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<img src={img} alt={`Thumbnail ${index + 1}`} />
</motion.button>
))}
</div>
</div>
);
}2. 多步表单
tsx
function MultiStepForm() {
const [step, setStep] = useState(0);
const steps = [<Step1 />, <Step2 />, <Step3 />];
const variants = {
enter: (direction: number) => ({
x: direction > 0 ? 1000 : -1000,
opacity: 0,
}),
center: {
x: 0,
opacity: 1,
},
exit: (direction: number) => ({
x: direction < 0 ? 1000 : -1000,
opacity: 0,
}),
};
const [direction, setDirection] = useState(0);
const goToNext = () => {
setDirection(1);
setStep((s) => s + 1);
};
const goToPrev = () => {
setDirection(-1);
setStep((s) => s - 1);
};
return (
<div className="form-container">
<div className="progress-bar">
{steps.map((_, index) => (
<motion.div
key={index}
className={`step ${index === step ? 'active' : ''}`}
animate={{
backgroundColor: index <= step ? '#3b82f6' : '#e5e7eb',
}}
/>
))}
</div>
<AnimatePresence custom={direction} mode="wait">
<motion.div
key={step}
custom={direction}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={{ duration: 0.3 }}
>
{steps[step]}
</motion.div>
</AnimatePresence>
<div className="buttons">
{step > 0 && <button onClick={goToPrev}>Previous</button>}
{step < steps.length - 1 && <button onClick={goToNext}>Next</button>}
{step === steps.length - 1 && <button>Submit</button>}
</div>
</div>
);
}3. Tab切换
tsx
function AnimatedTabs() {
const [activeTab, setActiveTab] = useState(0);
const tabs = [
{ id: 0, label: 'Overview', content: <Overview /> },
{ id: 1, label: 'Details', content: <Details /> },
{ id: 2, label: 'Reviews', content: <Reviews /> },
];
return (
<div className="tabs">
<div className="tab-headers">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={activeTab === tab.id ? 'active' : ''}
>
{tab.label}
{activeTab === tab.id && (
<motion.div
className="underline"
layoutId="underline"
transition={{
type: "spring",
stiffness: 500,
damping: 30,
}}
/>
)}
</button>
))}
</div>
<AnimatePresence mode="wait">
<motion.div
key={activeTab}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
>
{tabs[activeTab].content}
</motion.div>
</AnimatePresence>
</div>
);
}性能优化
预加载页面
tsx
function usePagePrefetch() {
const prefetchPage = (path: string) => {
const link = document.createElement('link');
link.rel = 'prefetch';
link.href = path;
document.head.appendChild(link);
};
return prefetchPage;
}
// 使用
function Navigation() {
const prefetch = usePagePrefetch();
return (
<nav>
<Link
to="/about"
onMouseEnter={() => prefetch('/about')}
>
About
</Link>
</nav>
);
}减少布局抖动
tsx
// ✅ 使用transform
const optimizedVariants = {
initial: { opacity: 0, x: -20 },
animate: { opacity: 1, x: 0 },
exit: { opacity: 0, x: 20 },
};
// ❌ 避免使用left/right
const unoptimizedVariants = {
initial: { opacity: 0, left: -20 },
animate: { opacity: 1, left: 0 },
exit: { opacity: 0, left: 20 },
};最佳实践总结
性能优化清单
✅ 使用transform和opacity属性
✅ 合理设置AnimatePresence mode
✅ 避免同时动画过多元素
✅ 预加载关键页面资源
✅ 使用layoutId优化共享元素
✅ 测试不同设备性能用户体验准则
✅ 保持过渡时间在200-400ms
✅ 为不同操作使用不同动画
✅ 提供跳过动画选项
✅ 尊重用户的动画偏好设置
✅ 确保动画有明确的目的可访问性要求
✅ 支持prefers-reduced-motion
✅ 确保键盘导航流畅
✅ 提供适当的ARIA标签
✅ 避免仅依赖动画传达信息
✅ 测试屏幕阅读器兼容性页面过渡动画是现代Web应用的重要组成部分。通过合理使用Framer Motion的过渡功能,你可以创建流畅自然的页面切换效果,显著提升应用的专业度和用户体验。