Skip to content

页面过渡动画

概述

页面过渡动画是提升用户体验的关键要素之一。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的过渡功能,你可以创建流畅自然的页面切换效果,显著提升应用的专业度和用户体验。