React: 过渡与动画

最后更新:2026-08-26

Tom 刚完成了一个数据仪表盘项目,产品经理反馈说页面切换太生硬——列表加载时数据"闪一下"就出来了,弹窗打开和关闭也是瞬间完成。Tom 意识到:功能完整不等于体验好。他需要给页面加上流畅的过渡动画。


1. 你将学到



2. 概念图解

下面的图帮助你在选择动画方案时快速决策:

100%
flowchart TD
    A[需要动画效果] --> B{效果复杂度}
    B -->|简单过渡| C[CSS Transition]
    B -->|关键帧动画| D[CSS Animation]
    B -->|复杂交互| E[Framer Motion]

    C --> C1["hover / focus 效果"]
    C --> C2["opacity / transform 渐变动画"]
    C --> C3["单个属性变化"]

    D --> D1["@keyframes 定义多阶段"]
    D --> D2["无限循环动画"]
    D --> D3["加载骨架屏"]

    E --> E1["mount/unmount 动画"]
    E --> E2["拖拽 / 手势"]
    E --> E3["列表 layout 动画"]
    E --> E4["Spring 物理动效"]

    style C fill:#e3f2fd,stroke:#1565c0
    style D fill:#e8f5e9,stroke:#2e7d32
    style E fill:#fff3e0,stroke:#e65100


3. 一个真实场景

Tom 的仪表盘包含三个核心交互:任务列表加载模态弹窗开关卡片拖拽排序。他的目标:

  1. 任务列表加载时,每条任务从左侧滑入
  2. 弹窗打开时背景淡入 + 弹窗缩放,关闭时反向动画
  3. 拖拽卡片时跟随手指,松手后弹性回位

他从最简单的 CSS transition 开始,逐步过渡到 Framer Motion 实现复杂动画。


(1) CSS Transition — 最简单的渐进增强

CSS Transition 适用于起始状态结束状态之间的平滑过渡。它不需要任何 JavaScript 库,通过 CSS 声明即可生效。

Transition 的四个核心子属性

CSS
/* transition: property duration timing-function delay */
.example {
  transition: opacity 0.3s ease 0s;
  /* 完整写法:
     transition-property: opacity
     transition-duration: 0.3s
     transition-timing-function: ease
     transition-delay: 0s
  */
}

常用的 timing-function 值:

▶ 示例 1:CSS Transition 实现手风琴组件

JSX 📖 仅展示
import { useState } from 'react'

const accordionData = [
  { title: '什么是 React?', content: 'React 是一个用于构建用户界面的 JavaScript 库。' },
  { title: '什么是组件?', content: '组件是 React 应用的最小独立单元,可以复用和组合。' },
  { title: '什么是 State?', content: 'State 是组件内部的可变数据,驱动 UI 更新。' },
]

function Accordion() {
  const [openIndex, setOpenIndex] = useState(null)

  function toggle(index) {
    setOpenIndex(openIndex === index ? null : index)
  }

  return (
    <div style={{ maxWidth: 500, fontFamily: 'sans-serif' }}>
      {accordionData.map((item, index) => (
        <div
          key={index}
          style={{
            border: '1px solid #e8e8e8',
            borderRadius: 8,
            marginBottom: 8,
            overflow: 'hidden',
          }}
        >
          <button
            onClick={() => toggle(index)}
            style={{
              width: '100%',
              padding: '12px 16px',
              background: openIndex === index ? '#e6f7ff' : '#fafafa',
              border: 'none',
              cursor: 'pointer',
              fontSize: 15,
              fontWeight: 600,
              textAlign: 'left',
              transition: 'background 0.2s ease',
            }}
          >
            {item.title}
          </button>
          <div
            style={{
              maxHeight: openIndex === index ? 80 : 0,
              padding: openIndex === index ? '12px 16px' : '0 16px',
              opacity: openIndex === index ? 1 : 0,
              transition: 'all 0.3s ease',
              color: '#666',
              fontSize: 14,
              lineHeight: 1.6,
            }}
          >
            {item.content}
          </div>
        </div>
      ))}
    </div>
  )
}
逻辑代码 57 行(超过 40 行限制,仅展示)

关键点: maxHeight + opacity + padding 同时过渡,实现内容区域的展开/收起效果。注意 maxHeight 值要略大于实际内容高度。


(2) CSS Animation — 关键帧驱动动画

当动画超过两个阶段(不止起始 → 结束),或者需要循环播放时,CSS Animation 是更好的选择。它通过 @keyframes 定义多阶段动画序列。

核心属性

CSS
/* animation: name duration timing-function delay iteration-count direction fill-mode */
.pulse {
  animation: pulse 2s ease-in-out infinite;
}

@keyframes pulse {
  0%   { transform: scale(1); opacity: 1; }
  50%  { transform: scale(1.05); opacity: 0.7; }
  100% { transform: scale(1); opacity: 1; }
}

▶ 示例 2:骨架屏加载动画

JSX
import './Skeleton.css'

function Skeleton({ count = 3 }) {
  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
      {Array.from({ length: count }).map((_, i) => (
        <div key={i} className="skeleton-row">
          <div className="skeleton-avatar" />
          <div style={{ flex: 1 }}>
            <div className="skeleton-line" style={{ width: '60%' }} />
            <div className="skeleton-line" style={{ width: '90%', marginTop: 8 }} />
          </div>
        </div>
      ))}
    </div>
  )
}

export default Skeleton
▶ 试一试
CSS
/* Skeleton.css */
.skeleton-row {
  display: flex;
  gap: 12px;
  align-items: center;
}

.skeleton-avatar {
  width: 44px;
  height: 44px;
  border-radius: 50%;
  background: linear-gradient(90deg, #eee 25%, #f5f5f5 50%, #eee 75%);
  background-size: 200% 100%;
  animation: shimmer 1.5s ease-in-out infinite;
}

.skeleton-line {
  height: 14px;
  border-radius: 4px;
  background: linear-gradient(90deg, #eee 25%, #f5f5f5 50%, #eee 75%);
  background-size: 200% 100%;
  animation: shimmer 1.5s ease-in-out infinite;
}

@keyframes shimmer {
  0%   { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}

骨架屏动画的原理: 通过 background-position 的移动,模拟加载中流光扫过的视觉效果。CSS Animation 的 infinite 让流光不断循环,直到真实内容加载完成替换骨架屏。


(3) Framer Motion — 复杂的 React 动画

Framer Motion 是 React 生态中最强大的动画库,它提供了三个核心能力:

能力 解决的问题 对应 API
进入/离开动画 组件 mount/unmount 时的动画 motion.div + AnimatePresence
layout 动画 列表排序变化时的平滑过渡 layout prop
手势动画 拖拽、悬停、点击手势 drag / whileHover / whileTap

安装

BASH
npm install framer-motion

▶ 示例 3:Framer Motion 实现待办事项列表

JSX 📖 仅展示
import { useState } from 'react'
import { motion, AnimatePresence, Reorder } from 'framer-motion'

const initialTodos = [
  { id: 1, text: '完成 React 动画学习', done: false },
  { id: 2, text: '重构仪表盘组件', done: false },
  { id: 3, text: '代码审查 PR #142', done: false },
  { id: 4, text: '更新项目文档', done: true },
  { id: 5, text: '修复导航栏响应式BUG', done: false },
]

function TodoList() {
  const [todos, setTodos] = useState(initialTodos)

  function toggleDone(id) {
    setTodos(prev =>
      prev.map(t => (t.id === id ? { ...t, done: !t.done } : t))
    )
  }

  function removeTodo(id) {
    setTodos(prev => prev.filter(t => t.id !== id))
  }

  return (
    <div style={{ maxWidth: 480, fontFamily: 'sans-serif' }}>
      <AnimatePresence>
        {todos.map(todo => (
          <motion.div
            key={todo.id}
            layout
            initial={{ opacity: 0, x: -60 }}
            animate={{
              opacity: 1,
              x: 0,
              background: todo.done ? '#f6ffed' : '#fff',
            }}
            exit={{ opacity: 0, x: 100, height: 0, marginBottom: 0 }}
            transition={{ type: 'spring', stiffness: 300, damping: 25 }}
            style={{
              display: 'flex',
              alignItems: 'center',
              justifyContent: 'space-between',
              padding: '12px 16px',
              marginBottom: 8,
              borderRadius: 8,
              border: '1px solid #f0f0f0',
              cursor: 'pointer',
            }}
            whileHover={{ scale: 1.02 }}
            whileTap={{ scale: 0.98 }}
          >
            <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
              <input
                type="checkbox"
                checked={todo.done}
                onChange={() => toggleDone(todo.id)}
                style={{ width: 18, height: 18, cursor: 'pointer' }}
              />
              <span
                style={{
                  textDecoration: todo.done ? 'line-through' : 'none',
                  color: todo.done ? '#999' : '#333',
                  fontSize: 15,
                }}
              >
                {todo.text}
              </span>
            </div>
            <button
              onClick={() => removeTodo(todo.id)}
              style={{
                border: 'none',
                background: 'none',
                color: '#ff4d4f',
                cursor: 'pointer',
                fontSize: 18,
                fontWeight: 'bold',
              }}
            >
              x
            </button>
          </motion.div>
        ))}
      </AnimatePresence>
    </div>
  )
}

export default TodoList
逻辑代码 84 行(超过 40 行限制,仅展示)

这段代码展示了 Framer Motion 四个关键特性:

  1. initial / animate / exit:定义组件的进入、显示和离开状态。AnimatePresence 使条件渲染也能触发退出动画
  2. layout prop:当列表项因为 done 状态变化而改变位置时,会自动平滑过渡到新位置,不需要计算坐标
  3. transition:使用 spring 物理弹簧动效,stiffness 控制弹簧刚度,damping 控制阻尼(数值越大越不弹)
  4. whileHover / whileTap:交互手势动画,鼠标悬停时放大 1.02 倍,按下时缩小到 0.98 倍

▶ 示例 4:Framer Motion 模态弹窗动画

JSX 📖 仅展示
import { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'

function Modal({ isOpen, onClose, children }) {
  return (
    <AnimatePresence>
      {isOpen && (
        <motion.div
          key="overlay"
          initial={{ opacity: 0 }}
          animate={{ opacity: 1 }}
          exit={{ opacity: 0 }}
          transition={{ duration: 0.2 }}
          style={{
            position: 'fixed',
            inset: 0,
            background: 'rgba(0,0,0,0.45)',
            display: 'flex',
            alignItems: 'center',
            justifyContent: 'center',
            zIndex: 1000,
          }}
          onClick={onClose}
        >
          <motion.div
            key="modal"
            initial={{ scale: 0.85, opacity: 0, y: 20 }}
            animate={{ scale: 1, opacity: 1, y: 0 }}
            exit={{ scale: 0.85, opacity: 0, y: 20 }}
            transition={{ type: 'spring', stiffness: 400, damping: 30 }}
            style={{
              background: '#fff',
              padding: 24,
              borderRadius: 12,
              minWidth: 320,
              boxShadow: '0 8px 32px rgba(0,0,0,0.12)',
              position: 'relative',
            }}
            onClick={e => e.stopPropagation()}
          >
            {children}
          </motion.div>
        </motion.div>
      )}
    </AnimatePresence>
  )
}

function App() {
  const [isOpen, setIsOpen] = useState(false)

  return (
    <div>
      <button
        onClick={() => setIsOpen(true)}
        style={{ padding: '8px 20px', fontSize: 15, cursor: 'pointer' }}
      >
        打开弹窗
      </button>

      <Modal isOpen={isOpen} onClose={() => setIsOpen(false)}>
        <h2 style={{ margin: '0 0 12px' }}>确认操作</h2>
        <p style={{ color: '#666', marginBottom: 20 }}>
          你确定要删除这条记录吗?此操作无法撤销。
        </p>
        <div style={{ display: 'flex', gap: 12, justifyContent: 'flex-end' }}>
          <button
            onClick={() => setIsOpen(false)}
            style={{ padding: '8px 20px', cursor: 'pointer' }}
          >
            取消
          </button>
          <button
            onClick={() => {
              alert('已删除')
              setIsOpen(false)
            }}
            style={{
              padding: '8px 20px',
              background: '#ff4d4f',
              color: '#fff',
              border: 'none',
              borderRadius: 6,
              cursor: 'pointer',
            }}
          >
            确认删除
          </button>
        </div>
      </Modal>
    </div>
  )
}
逻辑代码 89 行(超过 40 行限制,仅展示)

弹窗动画的设计要点:



4. 动画性能最佳实践

属性 触发阶段 性能 推荐度
transform Composite ✅ 最优 ⭐⭐⭐
opacity Composite ✅ 最优 ⭐⭐⭐
filter Paint ⚠️ 中等 ⭐⭐
box-shadow Paint ⚠️ 中等
width/height Layout ❌ 昂贵
top/left/margin Layout ❌ 昂贵
规则 说明
优先使用 transformopacity 这两个属性触发 compositor 线程,不引发 Layout/Paint
避免动画 width / height / top / left 这些属性触发 Layout 重排,消耗较大
使用 will-change 提示浏览器 will-change: transform, opacity 提前创建合成层
避免同时动画太多元素 超过 20 个元素同时动画可能导致掉帧
使用 requestAnimationFrame 在 JS 驱动的动画中避免使用 setTimeout

▶ 示例 5:CSS Keyframe 动画——加载骨架屏

JSX
function SkeletonCard() {
  return (
    <div style={{ border: '1px solid #f0f0f0', borderRadius: 8, padding: 16, overflow: 'hidden' }}>
      <div style={{
        height: 120, borderRadius: 4, marginBottom: 12,
        background: 'linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%)',
        backgroundSize: '200% 100%',
        animation: 'shimmer 1.5s infinite',
      }} />
      <div style={{
        height: 16, width: '60%', borderRadius: 4, marginBottom: 8,
        background: 'linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%)',
        backgroundSize: '200% 100%',
        animation: 'shimmer 1.5s infinite',
      }} />
      <div style={{
        height: 12, width: '40%', borderRadius: 4,
        background: 'linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%)',
        backgroundSize: '200% 100%',
        animation: 'shimmer 1.5s infinite',
      }} />
      <style>{`
        @keyframes shimmer {
          0% { background-position: 200% 0; }
          100% { background-position: -200% 0; }
        }
      `}</style>
    </div>
  )
}

function LoadingGrid() {
  return (
    <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 16, maxWidth: 800, margin: '0 auto' }}>
      {[1, 2, 3].map(i => <SkeletonCard key={i} />)}
    </div>
  )
}
▶ 试一试

❓ 常见问题

Q CSS Transition 和 CSS Animation 到底有什么区别?
A Transition 是"从 A 到 B"的两阶段过渡,由状态变化触发(如 hover),不支持循环。Animation 是多阶段关键帧动画,可以无限循环、反向播放、暂停/继续。简单场景用 transition,复杂序列用 animation。
Q Framer Motion 的 AnimatePresence 为什么有时不起作用?
A 最常见的原因是 key 没有正确设置。AnimatePresence 通过 key 追踪子元素,如果 key 不变,它会认为元素"更新"而非"移除",exit 动画不会触发。另外 AnimatePresence 的直接子元素必须是 motion 组件。
Q 移动端动画性能如何保障?
A 坚持只动画 transformopacity,这两个属性在移动端 GPU 合成层上运行。Framer Motion 默认使用 transform,性能有保障。如果必须在移动端做复杂动画,可以用 useWillChange hook 或手动设置 will-change
Q 动画导致页面布局偏移(Layout Shift)怎么办?
A 给动画元素预留固定尺寸,避免动画过程改变布局。Framer Motion 的 layout prop 可以用 layoutId 配合 AnimatePresence 实现布局稳定过渡。如果使用 CSS,考虑用 transform: translateX() 代替 left 做位移动画。
Q React 18 的 useTransition 和 Framer Motion 有什么区别?
A useTransition 是 React 内置的并发特性,标记某些状态更新为"非紧急"(如搜索结果过滤),让紧急更新(如输入框打字)优先响应,避免卡顿。Framer Motion 是动画库,处理 UI 元素的位移/缩放/透明度等视觉效果。两者解决不同问题:useTransition 解决"交互卡顿",Framer Motion 解决"视觉过渡"。

📖 小节


📝 作业

  1. 用 CSS Transition 实现一个"回到顶部"按钮:页面滚动超过 300px 时淡入显示,点击后平滑滚动到顶部,回到顶部后淡出隐藏。
  2. 用 CSS Animation 实现一个加载 Spinner:圆环旋转动画,@keyframes 控制 0deg 到 360deg 旋转,速度曲线用 linear 保持匀速。
  3. 用 Framer Motion 实现一个可拖拽的浮动按钮(FAB),drag prop 允许它在屏幕内任意拖拽,拖拽结束时用 spring 动效吸附到最近的屏幕边缘。
Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏