第十五章:React 动画天花板:Framer Motion 从基础到高级 —— 掌握手势、数据驱动与布局动画全技能

第一章. 启程:初识 Framer Motion 与第一个动画

摘要: 本章是您踏上 Framer Motion 动画之旅的第一步。我们将从最核心的概念出发,理解为什么 Motion for React 是一个现代化的动画库。接着,我们会完成项目的安装与配置,并最终亲手编写第一个声明式动画,体验其无与伦比的开发乐趣。


在本章中,我们将循序渐进,像探索一幅画卷一样,逐步揭开 Motion for React 的神秘面纱:

  1. 首先,我们将理解其 核心理念,特别是其独特的“混合引擎”如何为我们带来高性能与高灵活性的动画体验。
  2. 接着,我们将动手完成 工程化集成,将 Motion for React 顺利地安装到我们的项目中。
  3. 最后,我们将迎来第一个“啊哈!”时刻,使用其核心的 motion 组件 创建第一个属于我们自己的动画效果。

1.1. 核心理念:什么是 Framer Motion?

在深入代码之前,我们必须先理解一个工具的设计哲学。Motion for React (后文简称 Motion) 是一个为 React 设计的、易于上手且功能强大的动画库。它旨在让创建流畅、美观的动画变得简单直观。

它最大的技术亮点在于其独特的 “混合引擎”

核心概念解析
2025-10-16 10:30
M

什么是“混合引擎”?听起来很复杂。

E
expert

它的原理其实很直接。它结合了两种动画技术的优点。

E
expert

一方面,它尽可能利用原生浏览器动画,实现硬件加速,保证动画如丝般顺滑,性能极高。

E
expert

另一方面,它又具备 JavaScript 动画的无限潜力,可以让我们实现一些 CSS 无法完成的复杂、动态的交互效果。

M

明白了,所以它既有高性能,又有高灵活性。

正是因为这个特性,Motion 被业界知名的设计与原型工具 Framer 所信赖,用于驱动其平台中所有无代码动画和手势的实现。它为我们的 UI 提供了从简单到复杂的多种动画实现方式。

本节小结

  • 核心定位: Motion for React 是一个为 React 设计的、易学且强大的生产级动画库。
  • 关键特性: 其独有的“混合引擎”结合了硬件加速的高性能和 JavaScript 动画的高灵活性。

1.2. 工程化集成:在 React 项目中安装与配置

在理解了 Motion 的核心理念后,我们现在就把它集成到我们的项目中。这个过程非常简单。

我们通过 npmpnpm 等包管理器进行安装。

1
pnpm install motion

安装完成后,我们就可以在 React 组件中通过 "motion/react" 路径导入所需的功能,最核心的就是 motion 组件本身。

1
import { motion } from "motion/react";
模块解惑
2025-10-16 10:35
M

为什么安装的是 motion,但从 "motion/react" 导入?

E
expert

这是一个很好的问题。motion 是一个通用的 JavaScript 动画库,它本身是与框架无关的。

E
expert

"motion/react" 路径下提供了专门为 React 定制的 API,比如我们即将学习的 <motion /> 组件,它封装了底层的逻辑,让我们可以用声明式的方式在 React 中使用动画。

M

所以,我们实际上是在使用 Motion 库的 React 版本。

E
expert

完全正确。

本节小结

  • 安装命令: pnpm install motion 是将库添加到项目的标准方式。
  • 核心导入: import { motion } from "motion/react" 是我们使用 React 版本 Motion API 的入口。

1.3. “啊哈!”时刻:使用 motion 组件创建你的第一个动画

现在,激动人心的时刻到了。我们将使用 Motion 的核心——motion 组件,来创建第一个动画。

motion 组件本质上是一个被动画能力增强的普通 DOM 元素。我们可以像 motion.div, motion.button, motion.ul 这样来使用它。

让我们先来看一个最简单的例子:让一个列表旋转 360 度,请点击下方的实时预览代码窗口体验旋转效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import * as motion from "motion/react-client"

const box = {
width: 100,
height: 100,
backgroundColor: "#ff0088",
borderRadius: 5,
}

export default function App() {
return (
<motion.div
style={box}
animate={{ rotate: 360 }}
transition={{ duration: 1 }}
/>
)
}

就是这么简单!我们只需要在 animate 属性中定义我们希望元素动画 的最终状态,Motion 就会自动处理中间的过渡。当 animate 中的任何值发生改变时,组件都会自动生成动画过渡到新的目标状态。

入场动画

在实际应用中,我们最常做的是“入场动画”,即组件在首次挂载到页面时播放的动画。这通常需要两个属性来配合:

  • initial: 定义动画的 起始 状态。
  • animate: 定义动画的 结束 状态。

让我们看一个球形从缩放为 0(不可见)放大到正常尺寸的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import * as motion from "motion/react-client";
const ball = {
width: 100,
height: 100,
backgroundColor: "#dd00ee",
borderRadius: "50%",
};
export default function EnterAnimation() {
return (
<motion.div
// 默认透明度是0缩放为0
initial={{ opacity: 0, scale: 0 }}
// 动画结束时透明度为1缩放为1
animate={{ opacity: 1, scale: 1 }}
// 动画持续时间0.4秒缩放使用弹簧效果视觉持续时间0.4秒弹跳0.5次
transition={{
duration: 0.4,
scale: { type: "spring", visualDuration: 0.4, bounce: 0.5 },
}}
style={ball}
/>
);
}

Motion 会智能地在 initialanimate 两个状态之间创建平滑的过渡。如果我们不提供 initial 状态,Motion 会尝试从 DOM 中读取元素的当前状态作为动画的起始点。

如果我们不希望组件在首次加载时播放入场动画,可以设置 initial={false}。这会让元素直接以 animate 中定义的状态进行渲染。

🤔 思考一下:如果我们不给 initial 属性,Motion for React 怎么知道动画从哪里开始呢?

initial 属性未被定义时,Motion for React 会在组件首次渲染后,从真实的 DOM 中读取元素的当前样式(例如,由 CSS 文件定义的样式),并将其作为动画的起始状态。这就是它能够实现“从任意当前状态到目标状态”的动画的原因。

本节小结

  • 核心组件: motion 组件是所有动画的基础,通过 motion.div 等形式使用。
  • 核心 API: animate 属性用于定义动画的目标状态。
  • 入场动画: 通过组合 initial (起始状态) 和 animate (结束状态) 两个属性来实现组件的首次加载动画。

1.4. 本章小结

在本章中,我们为后续的学习打下了坚实的基础。我们不仅理解了 Motion for React 作为一个现代化动画库的核心价值和设计理念,还成功地将其集成到了我们的项目中,并迈出了至关重要的第一步——创建了一个简单但完整的声明式动画。

核心概念描述
Motion for React一个专为 React 打造的、兼具高性能与高灵活性的生产级动画库。
混合引擎结合硬件加速和 JavaScript 动画的优势,实现流畅且强大的动画效果。
motion 组件动画的基本载体,通过 motion.tag 的形式使用,例如 motion.div
animate 属性定义动画的 目标状态,是实现动画最核心的声明式 API。
initial 属性定义动画的 初始状态,常与 animate 配合实现入场动画。

我们已经掌握了让静态元素“动起来”的最基本方法。在下一章中,我们将深入探索 motion 组件的更多核心 API,学习如何更精细地控制动画的表现。


第二章. 核心动画 API:motion 组件与三大支柱

摘要: 在上一章中,我们已经掌握了如何使用 initialanimate 来创建基础的入场动画。本章我们将在此基础上,构建起对 Motion 动画生命周期的完整认知,引入 exit 属性来处理组件的离场。更重要的是,我们将学习 variantskeyframes 这两大核心特性,它们是实现可维护、可编排的复杂动画的基石。


在本章中,我们将深入 motion 组件的核心,探索其最强大的功能:

  1. 首先,我们将补全动画生命周期的最后一环——离场动画,学习如何使用 AnimatePresence 组件和 exit 属性,优雅地处理元素的消失。
  2. 接着,我们将掌握 variants (变体),这是一种极其强大的模式,能帮助我们组织、复用动画状态,并轻松实现父子组件间的动画编排。
  3. 最后,我们将学习 关键帧 (Keyframes),通过它来定义更复杂、多阶段的动画路径,让动画表现力更上一层楼。

2.1. 动画三要素:initial, animate 与 exit 属性

在上一章中,我们已经成功地让组件 “跑起来” 了。我们知道 initial 定义了从哪里开始,animate 定义了到哪里去。但一个完整的生命周期还包括“如何消失”。这就是 exit 属性的用武之地。

痛点背景:React 中的组件销毁

在标准的 React 工作流中,当一个组件因为条件渲染(例如 isVisible && <Component />)从 true 变为 false 时,React 会立即将其从 DOM 中移除。这意味着我们没有任何时机去播放一个“离场动画”。

解决方案:AnimatePresenceexit

为了解决这个问题,Motion 提供了 AnimatePresence 组件。它的作用就像一个“暂缓执行官”,当它的直接子组件即将从 DOM 中移除时,它会:

  1. 暂停移除:让组件继续保留在 DOM 中。
  2. 播放动画:寻找该组件上的 exit 属性,并播放其中定义的动画。
  3. 完成移除:在 exit 动画播放完毕后,才真正地将组件从 DOM 中移除。

让我们来看一个经典的 Modal (模态框) 淡入淡出的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import { AnimatePresence, motion } from "motion/react";
import { useState } from "react";

export default function App() {
// 1.定义状态
const [isVisible, setIsVisible] = useState(true);

return (
<div className="flex flex-col w-20 h-40 relative">
{/* 2.使用AnimatePresence组件包裹需要动画的组件 */}
<AnimatePresence initial={false}>
{isVisible ? (
<motion.div
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0 }}
className="w-20 h-20 bg-blue-500 rounded-md"
key="box"
/>
) : null}
</AnimatePresence>
<motion.button
className="bg-blue-500 rounded-md p-2 text-black absolute bottom-0 left-0 right-0"
onClick={() => setIsVisible(!isVisible)}
whileTap={{ y: 1 }}
>
{isVisible ? "Hide" : "Show"}
</motion.button>
</div>
);
}

关键规则: AnimatePresence 的直接子组件 必须 拥有一个唯一的 key 属性。这是 React 和 Motion 识别哪个组件正在进入或离开的唯一方式。

本节小结

  • 动画三要素: initial (入场前)、animate (入场后)、exit (离场时) 构成了 Motion 组件完整的动画生命周期。
  • AnimatePresence: 是启用 exit 动画的 必要 包裹组件。
  • key 属性: 是 AnimatePresence 正确追踪和管理其子组件动画状态的 强制 要求。

2.2. 状态驱动动画:variants 的妙用与最佳实践

直接在 animate 属性中写动画对象,对于简单的、单个元素的动画来说非常方便。但随着应用变得复杂,我们会遇到新的挑战:

  • 代码重复: 多个元素可能有相同的动画状态(如“显示”/“隐藏”)。
  • 编排困难: 如何让父组件的动画与子组件的动画协同工作,比如让子元素依次入场?

为了解决这些问题,Motion 引入了 variants (变体) 的概念。

什么是 Variants?

variants 是一组预先定义好的、带有名称的动画目标对象。我们可以将这些“动画状态”集中管理,然后在组件中通过名称来引用它们。

文件路径:src/motions/variants.ts

1
2
3
4
export const boxVariants = {
visible: { opacity: 1, scale: 1 },
hidden: { opacity: 0, scale: 0.5 },
};

文件路径:src/motions/variants.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { AnimatePresence, motion } from "motion/react";
import { boxVariants } from "./motions/variants";

const App = () => {
return (
<AnimatePresence>
<motion.div
initial="hidden"
animate="visible"
exit="hidden"
variants={boxVariants}
className="w-20 h-20 bg-blue-500 rounded-md"
>
<p>Hello</p>
</motion.div>
</AnimatePresence>
);
};

export default App;

这种方式极大地提升了代码的可读性和可维护性,将“动画的定义”与“动画的触发”分离开来。

变体传播与编排

variants 最强大的地方在于它能够在 motion 组件树中自动传播。当一个父组件的 animate 状态改变时(例如从 “hidden” 到 “visible”),所有同样定义了 “hidden” 和 “visible” 变体的子 motion 组件也会自动触发相应的动画,无需手动管理。

更进一步,我们可以在父组件的 transition 属性中,使用特殊的编排属性来控制子动画的行为。

  • delayChildren: 延迟所有子动画的开始时间。
  • staggerChildren: 在子动画之间创建一个交错的延迟,实现依次入场/离场的效果。
  • when: 定义父动画与子动画的执行顺序(例如 "beforeChildren" 表示父动画先于子动画执行)。

这类场景特别适合列表加载动画,如下:

img

文件路径:src/motions/variants.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
export const listVariants = {
visible: {
opacity: 1,
transition: {
when: "beforeChildren", // 父动画先执行
staggerChildren: 0.2, // 每个子元素动画延迟 0.2 秒
},
},
hidden: {
opacity: 0,
transition: {
when: "afterChildren",
},
},
};

// 列表项的变体
export const itemVariants = {
visible: { opacity: 1, x: 0 },
hidden: { opacity: 0, x: -100 },
};

文件路径:src/App.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { AnimatePresence, motion } from "motion/react";
import { itemVariants, listVariants } from "./motions/variants";
const listItems = ["Hello", "World", "React", "Motion", "Animation"];

const App = () => {
return (
<AnimatePresence>
<motion.ul
initial="hidden"
animate="visible"
exit="hidden"
variants={listVariants}
className="rounded-md list-none p-0 m-0"
>
{listItems.map((item) => (
<motion.li
variants={itemVariants}
key={item}
className="bg-blue-500 rounded-md p-2 m-2"
>
{item}
</motion.li>
))}
</motion.ul>
</AnimatePresence>
);
};

export default App;

本节小结

  • 核心价值: variants 是一种组织、复用和解耦动画逻辑的强大模式。
  • 自动传播: 父组件的 animate 状态会自动传递给所有后代 motion 组件,触发同名变体。
  • 动画编排: 结合 staggerChildrentransition 属性,可以轻松实现复杂的、时序协调的父子动画。

2.3. 关键帧动画:定义复杂的动画路径

有时候,我们需要的动画不是简单的“从 A 到 B”,而是一个包含多个中间步骤的序列,比如“从 A 到 B,再到 C”。这就是关键帧 (Keyframes) 动画。

在 Motion 中,实现关键帧动画非常直观:只需将 animate 属性的值设置为一个数组。

1
2
3
4
5
6
<motion.div
// 动画路径:从 x=0 -> x=200 -> x=100 -> x=0
animate={{ x: [0, 200, 100, 0] }}
transition={{ duration: 2 }}
style={{ width: 50, height: 50, background: "red" }}
/>

默认情况下,每个关键帧在整个动画时长 (duration) 中是均匀分布的。

平滑中断与 null

一个常见的场景是,一个正在进行的动画被一个新的关键帧动画打断。为了让过渡更自然,我们可以在关键帧数组的开头使用 nullnull 会被 Motion 动态地替换为动画被打断时的当前值。

img

1
2
// 如果动画在 x=50 的位置被打断,则实际动画路径会变成 [50, 200, 100]
<motion.div animate={{ x: [null, 200, 100] }} />

自定义时间分布:times 属性

如果我们不希望关键帧均匀分布,可以通过 transition 中的 times 属性来精确控制每个关键帧在动画总时长中的时间点。

times 是一个与关键帧数组长度相同的、值在 01 之间的数组,代表了动画的进度。

1
2
3
4
5
6
7
8
9
10
11
<motion.div
animate={{ x: [0, 200, 100, 0] }}
transition={{
duration: 3,
// 时间点分布:
// 0 -> 200: 占用 50% 的时间 (0.5 - 0)
// 200 -> 100: 占用 30% 的时间 (0.8 - 0.5)
// 100 -> 0: 占用 20% 的时间 (1.0 - 0.8)
times: [0, 0.5, 0.8, 1],
}}
/>

本节小结

  • 基本语法: 将 animate 的值设置为一个数组即可创建关键帧动画。
  • 平滑过渡: 在关键帧数组开头使用 null 可以让动画在被中断时从当前值平滑地开始。
  • 精细控制: 使用 transitiontimes 属性可以自定义每个关键帧的时间分布,实现非线性的动画节奏。

2.4. 本章小结

在本章中,我们深入探索了 motion 组件的三大核心支柱,极大地扩展了我们的动画能力。我们不仅构建了完整的动画生命周期认知,还学会了两种组织和定义复杂动画的强大模式。

核心 API描述关键用途
exit 属性定义组件从 DOM 中移除时的动画状态。必须与 <AnimatePresence> 组件配合使用,实现优雅的离场动画。
variants 属性一组预定义的、带名称的动画目标,用于组织和复用动画逻辑。状态管理、代码解耦、以及通过 staggerChildren 实现父子动画编排。
Keyframes (数组)animate 中使用数组来定义一个多阶段的动画序列。创建非线性的、路径复杂的动画效果,可通过 times 精确控制节奏。

掌握了这些核心 API,我们已经具备了构建绝大多数声明式 UI 动画的能力。在下一章中,我们将进一步学习如何通过 transition 属性,来精细化地调整动画的“感觉”,例如改变动画的物理模型(弹簧或缓动)和持续时间。


第三章. 精通 transition 属性:定义动画的动态行为

摘要: 如果说 animate 属性决定了动画的“终点”,那么 transition 属性就定义了“如何到达终点”的整个过程。本章,我们将深入 transition 对象,学习如何精细地控制动画的“个性”与“感觉”。我们将掌握两大核心动画类型——tweenspring——的区别与应用场景,并学会使用编排属性来创建复杂的、富有节奏感的动画序列。


在本章中,我们将像一位动画导演一样,精雕细琢动画的每一个动态细节:

  1. 首先,我们将 深入 transition 对象,理解其基本结构以及如何为不同的动画属性设置专属的过渡效果。
  2. 接着,我们将深入对比两种最核心的动画物理模型:tween (缓动)spring (弹簧),并学会根据场景选择最合适的模型及其参数。
  3. 最后,我们将学习强大的 编排与协同 能力,通过 delayrepeat 以及与 variants 结合的 staggerChildren,创造出优雅的动画时序。

3.1. 深入 transition 对象:动画的“灵魂”

在前面的章节中,我们创建的动画都使用了 Motion 的默认过渡效果。这些默认值非常出色,能满足大部分日常需求。但要成为动画大师,我们必须学会如何定制动画的动态行为,赋予其独特的“灵魂”——这正是 transition 属性的职责。

transition 是一个可以被设置在任何动画属性(如 animate, whileHover 等)上的对象,它定义了动画从起始值到目标值所采用的过渡类型和参数。

一个基础的 transition 对象可能如下所示:

文件路径:src/App.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { motion } from "motion/react";

// 1. 定义一个可复用的 transition 对象
const basicTransition = {
duration: 0.8, // 动画持续时间
delay: 0.5, // 动画延迟开始的时间
// ease 定义缓动曲线,这里是一个自定义的贝塞尔曲线
ease: [0, 0.71, 0.2, 1.01],
};

export default function App() {
return (
<motion.div
className="w-20 h-20 bg-blue-500 rounded-md"
// 2. transition 对象应用到 animate 属性
animate={{ x: 200 }}
transition={basicTransition}
/>
);
}

值为导向的过渡

在更复杂的场景中,我们可能希望一个元素的不同属性使用不同的过渡效果。例如,让 opacity (透明度) 线性变化,而 x (位置) 则带有弹簧效果。transition 对象允许我们通过属性名来定义这种差异化过渡。

我们首先看一个所有属性共享同一个过渡的例子:

文件路径:src/App.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { motion } from "motion/react";

export default function App() {
return (
<motion.div
className="w-20 h-20 bg-blue-500 rounded-md"
initial={{ x: -200, opacity: 0 }}
animate={{
x: 0,
opacity: 1,
// 在这里x opacity 都会使用这个 spring 过渡
transition: {
type: "spring",
},
}}
/>
);
}

现在,让我们进行改造,为 opacity 设置一个专属的过渡效果。我们可以通过 default 键为所有未指定的属性设置一个默认过渡,然后为特定属性(如 opacity)单独配置。

文件路径:src/App.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { motion } from "motion/react";

export default function App() {
return (
<motion.div
className="w-20 h-20 bg-blue-500 rounded-md"
initial={{ x: -200, opacity: 0 }}
animate={{
x: 0,
opacity: 1,
transition: {
// 为所有未明确指定的属性设置默认过渡
// stiffness弹性系数 越高 动画越快
default: { type: "spring", stiffness: 20 },
// opacity 属性设置专属过渡
opacity: { ease: "linear", duration: 2 },
},
}}
/>
);
}

通过这种方式,x 会以 spring 的物理效果移动,而 opacity 则会在 2 秒内线性地变化,实现了精细化的控制。

本节小结

  • 核心作用: transition 属性用于定义动画的动态行为,如时长、延迟和缓动曲线。
  • 应用方式: 它可以作为一个独立对象定义并复用,或直接内联在动画属性中。
  • 差异化控制: transition 对象支持为不同的 CSS 属性(如 x, opacity)配置不同的过渡效果,提供了极高的灵活性。

3.2. 动画类型详解:spring vs tween

Motion 的强大之处在于它内置了多种动画物理模型。transition 对象中的 type 属性就是用来选择这些模型的关键。我们主要关注两种最常用、最重要的类型:tweenspring

动画类型选择
2025-10-17 14:00
M

tweenspring 有什么本质区别?我应该在什么时候用哪一个?

E
expert

问得好!这正是区分动画初学者和专家的关键点。

E
expert

tween 是基于 时间 的。你明确告诉它“在 X 秒内完成”,并给它一个缓动曲线(ease)。它非常可预测,适合需要精确控制时长的 UI 过渡,比如面板的展开和折叠。

E
expert

spring 则是基于 物理 的。你不用关心时长,而是像调整真实世界的弹簧一样,设置它的“刚度(stiffness)”、“阻尼(damping)”等参数。它会根据物理定律自然地运动和停止,感觉更生动、更有机,非常适合响应用户操作的交互,比如拖拽释放、模态框弹出等。

M

明白了,tween 追求精确控制,spring 追求自然感觉。

3.2.1. tween:可预测的缓动动画

tween 是我们最熟悉的动画类型,与传统 CSS transition 非常相似。它的核心是 duration (持续时间)ease (缓动函数)

文件路径:src/App.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { motion } from "motion/react";

export default function App() {
return (
<motion.div
className="w-20 h-20 bg-blue-500 rounded-md"
animate={{ rotate: 180 }}
transition={{
type: "tween", // 明确指定类型为 tween
duration: 2, // 持续 2
ease: "easeInOut", // 使用 "easeInOut" 缓动曲线
}}
/>
);
}

Motion 提供了多种预设的 ease 字符串,如 "linear", "easeIn", "easeOut", "circIn", "backOut" 等。对于更精细的控制,我们也可以传入一个包含四个数字的数组来定义一个自定义的 贝塞尔曲线

3.2.2. spring:生动的物理弹簧

spring 动画让我们的 UI 感觉像是活了过来。它有两种配置模式:

模式一:时长模式 (Duration-based)

这是 spring 的简化模式,更易于理解。我们通过 durationbounce (弹力)来定义它。bounce 的值在 0 到 1 之间,0 表示没有弹力,1 表示极强的弹力。

文件路径:src/App.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { motion } from "motion/react";

export default function App() {
return (
<motion.div
className="w-20 h-20 bg-blue-500 rounded-md"
whileHover={{ scale: 1.2 }}
transition={{
type: "spring",
duration: 0.8,
bounce: 0.5 // 适度的弹力
}}
/>
);
}

模式二:物理模式 (Physics-based)

这是 spring 最强大、最真实的模式。它通过模拟真实物理参数来工作,主要有三个:

  • stiffness: 刚度。值越高,弹簧力越强,动画移动得越快、越突然。
  • damping: 阻尼。值越高,抑制振荡的力越强,动画会更快地稳定下来。
  • mass: 质量。值越高,物体越重,惯性越大,移动起来感觉越“迟钝”。

注意: 物理模式的参数 (stiffness, damping, mass) 与时长模式的参数 (duration, bounce) 是互斥的。一旦设置了任何一个物理参数,时长模式的参数就会被忽略。

文件路径:src/App.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { motion } from "motion/react";

export default function App() {
return (
<motion.div
className="w-20 h-20 bg-blue-500 rounded-md"
whileTap={{ scale: 0.8 }}
transition={{
type: "spring",
stiffness: 400, // 较高的刚度
damping: 10, // 较低的阻尼会产生一些振荡
}}
/>
);
}

本节小结

  • tween: 基于 时间,通过 durationease 控制,效果可预测,适合标准 UI 过渡。
  • spring: 基于 物理,通过 bouncestiffness, damping 等参数控制,效果自然生动,适合交互反馈。
  • 模式选择: spring 的两种模式(时长 vs 物理)是互斥的,物理模式提供了更高的真实感和控制力。

3.3. 编排与协同:delay, repeatstaggerChildren

除了定义单个动画的行为,transition 属性还赋予了我们编排多个动画、或让单个动画循环播放的能力。

3.3.1. 基础编排:delayrepeat

  • delay: 延迟动画的开始时间(单位:秒)。
  • repeat: 动画重复的次数。可以设置为数字,或 Infinity 来实现无限循环。
  • repeatType: 重复的类型。
    • "loop" (默认): 从头开始重复。
    • "reverse": 反向播放动画,实现往返效果。
  • repeatDelay: 每次重复之间的延迟时间。

文件路径:src/App.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { motion } from "motion/react";

export default function App() {
return (
<motion.div
className="w-20 h-20 bg-blue-500 rounded-md"
animate={{ rotate: 180 }}
transition={{
repeat: Infinity, // 无限循环
repeatType: "reverse", // 往返运动
duration: 2, // 单次动画时长
repeatDelay: 1, // 每次重复间隔 1
}}
/>
);
}

3.3.2. 高级协同:与 variants 结合

transition 的编排能力在与第二章学习的 variants 结合时才能完全释放。我们可以在父元素的 varianttransition 中使用 when, delayChildrenstaggerChildren 来精确控制子元素的动画时序。

  • when: 定义父子动画的执行顺序 ("beforeChildren""afterChildren")。
  • delayChildren: 整体延迟所有子动画的开始时间。
  • staggerChildren: 为子元素之间设置一个交错的延迟,是创建列表依次入场动画的核心。

让我们再次回顾并巩固第二章经典的列表加载动画例子,现在我们可以更深刻地理解其 transition 的配置:

文件路径:src/motions/variants.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
export const listVariants = {
visible: {
opacity: 1,
transition: {
when: "beforeChildren", // 确保父容器先变为可见
staggerChildren: 0.2, // 每个子项依次延迟 0.2 秒入场
},
},
hidden: {
opacity: 0,
transition: {
when: "afterChildren", // 确保子项先完成后,父容器再变为透明
},
},
};

export const itemVariants = {
visible: { opacity: 1, x: 0, transition: { type: "spring", stiffness: 100 } },
hidden: { opacity: 0, x: -100 },
};

文件路径:src/App.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { motion } from "motion/react";
import { itemVariants, listVariants } from "./motions/variants";

const listItems = ["Apple", "Banana", "Cherry", "Date", "Elderberry"];

export default function App() {
return (
<motion.ul
className="p-0 m-0 list-none"
initial="hidden"
animate="visible"
variants={listVariants}
>
{listItems.map((item) => (
<motion.li
key={item}
className="bg-blue-500 text-white rounded-md p-4 m-2"
variants={itemVariants}
>
{item}
</motion.li>
))}
</motion.ul>
);
}

本节小结

  • 基础控制: delayrepeat 提供了对单个动画时序和循环的基础控制。
  • 核心能力: staggerChildren 是实现优美、交错的列表动画的关键。
  • 最佳实践: 将编排属性(如 staggerChildren)定义在父元素的 varianttransition 中,是实现复杂协同动画的最佳模式。

3.4. 本章小结

在本章中,我们彻底掌握了 transition 属性,学会了如何从一个动画的“旁观者”转变为“导演”。我们现在不仅能决定动画的起点和终点,更能精雕细琢其间的每一个动态细节。

核心 transition 属性描述关键用途
type定义动画的物理模型,主要为 "tween""spring"决定动画是基于时间(可预测)还是基于物理(自然生动)。
duration(tweenspring 时长模式) 动画的持续时间。精确控制动画时长。
ease(tween) 动画的缓动曲线。定义动画的速度变化,如先快后慢、匀速等。
stiffness, damping(spring 物理模式) 弹簧的刚度和阻尼。创造高度真实的、物理驱动的弹簧效果。
delay, repeat延迟和重复动画。控制动画的播放时机和循环。
staggerChildren(在 variants 中使用) 子元素动画的交错延迟。创建优雅的列表依次进入/离开效果。

我们已经具备了创造出既美观又富有表现力的动画所需的几乎所有知识。在下一章,我们将把注意力从“如何动”转向“何时动”,深入学习如何让动画响应用户的各种手势和交互。


第四章. 手势与交互:让动画响应用户

摘要: 在前几章中,我们的动画都是预设好并自动播放的。从本章开始,我们将赋予动画“生命”,让它们能够感知并响应用户的交互行为。我们将学习 Motion 强大的手势系统,涵盖悬停、点击、拖拽等核心交互,并探索如何利用滚动来触发动画,极大地丰富应用的用户体验。


在本章中,我们将把用户置于动画体验的中心:

  1. 首先,我们将掌握最基础也是最常用的交互:whileHoverwhileTapwhileFocus,让组件在用户悬停、点击或聚焦时,提供即时的视觉反馈。
  2. 接着,我们将深入学习强大的 拖拽手势,不仅能让元素自由拖动,还能为其设置边界约束,实现如卡片滑动等复杂交互。
  3. 最后,我们将探索 视口感知 能力,使用 whileInView 让元素在进入屏幕可视区域时才触发动画,这是现代网页中实现“滚动动画”最优雅的方式。

4.1. 基础交互:用户操作

Motion 提供了一系列以 while 开头的便捷属性,专门用于处理最常见的用户交互。这些属性的使用方式与 animate 完全相同,都是定义一个动画目标,但它们只在特定的交互状态下才会被触发。

  • whileHover: 当鼠标指针悬停在元素上时触发。
  • whileTap: 当用户在元素上按下鼠标左键或触摸时触发。
  • whileFocus: 当元素获得焦点时触发(通常用于输入框、按钮等可交互元素)。

让我们创建一个交互式按钮来直观感受它们的效果。

文件路径:src/App.tsx

img

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { motion } from "motion/react";

export default function App() {
return (
<motion.button
className="bg-blue-500 text-white font-bold py-3 px-6 rounded-lg text-xl"
// 鼠标悬停时按钮放大 1.1
whileHover={{
scale: 1.1,
// 可以为特定手势定义专属的 transition
transition: { type: "spring", stiffness: 300 },
}}
// 点击按下按钮缩小到 0.9
whileTap={{ scale: 0.9 }}
>
Click Me
</motion.button>
);
}

当用户的交互状态结束时(例如鼠标移开或松开),元素会自动、平滑地返回到 animate 属性所定义的状态。这种自动处理进入和返回动画的机制,极大地简化了交互式动画的开发。

本节小结

  • 核心理念: while... 系列属性提供了声明式的方式来定义特定交互状态下的动画目标。
  • 自动返回: 无需手动处理,当交互结束后,动画会自动返回到原始状态。
  • 独立过渡: 我们可以为每个手势动画定义其专属的 transition,实现更丰富的交互效果。

4.2. 拖拽手势:drag 属性与约束

拖拽是移动端和桌面端应用中一种非常重要的交互方式。Motion 仅用一个 drag 属性就为我们开启了功能完备的拖拽世界。

要让一个元素变得可拖拽,只需为其添加 drag 属性。

1
2
3
4
5
<motion.div
className="w-20 h-20 bg-blue-500 rounded-full cursor-grab"
drag // 启用拖拽
whileTap={{ cursor: "grabbing" }} // 拖拽时改变鼠标样式
/>

默认情况下,元素可以在整个页面上自由拖拽。通过设置 drag 属性的值,我们可以将其约束在特定方向上:

  • drag="x": 仅允许水平拖拽。
  • drag="y": 仅允许垂直拖拽。

拖拽约束:dragConstraints

在大多数场景下,我们不希望元素可以被无限拖拽。dragConstraints 属性允许我们定义一个“边界框”,元素只能在这个框内移动。

dragConstraints 可以接收两种类型的值:

  1. 一个对象: { top, left, right, bottom },定义了相对于元素初始位置的边界。
  2. 一个 ref: 指向另一个 DOM 元素,该元素的边界框将成为拖拽区域。这是最常用、最灵活的方式。

文件路径:src/App.tsx

img

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { motion } from "motion/react";
import { useRef } from "react";

export default function App() {
// 1. 创建一个 ref 来引用约束容器
const constraintsRef = useRef(null);
return (
// 2. 将 ref 绑定到容器元素上
<div
ref={constraintsRef}
className="w-80 h-80 bg-slate-200 rounded-lg flex justify-center items-center mx-auto"
>
<motion.div
className="w-20 h-20 bg-blue-500 rounded-full cursor-grab"
drag
// 3. ref 作为拖拽约束
dragConstraints={constraintsRef}
whileTap={{ cursor: "grabbing" }}
/>
</div>
);
}

本节小结

  • 快速启用: 只需添加 drag 属性即可让任何 motion 组件变得可拖拽。
  • 方向锁定: 通过 drag="x"drag="y" 可以将拖拽限制在单一轴向。
  • 边界控制: dragConstraints 是实现有边界拖拽的核心属性,使用 ref 来指定一个容器作为边界是最推荐的最佳实践。

4.3. 视口感知:whileInView 与滚动触发动画

在现代长页面设计中,当用户滚动到特定区域时才播放动画,是一种非常流行且能有效提升性能和用户体验的技术。Motion 为此提供了极其简洁的解决方案:whileInView 属性。

whileInView 的工作方式与 whileHover 类似:当它所应用的元素进入浏览器的可视区域时,它会触发其中定义的动画。当元素滚动出可视区域时,它会自动返回到 initialanimate 状态。

文件路径:src/App.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { motion } from "motion/react";

export default function App() {
return (
// 为了演示,我们创建一个可滚动的容器
<div className="h-[200vh] pt-[50vh]">
<motion.div
className="w-40 h-40 bg-blue-500 rounded-lg mx-auto"
// 动画的初始状态
initial={{ opacity: 0, scale: 0.5 }}
// 当元素进入视口时动画到这个状态
whileInView={{ opacity: 1, scale: 1 }}
// 可以定义过渡效果
transition={{ duration: 0.8 }}
/>
</div>
);
}

精细化控制:viewport 属性

默认情况下,只要元素的任何一个像素进入视口,whileInView 就会触发。我们可以通过 viewport 属性来更精细地控制触发条件。

  • viewport={{ once: true }}: 动画只会播放一次。当元素进入视口后,即使之后滚出再滚入,动画也不会再次播放。这是最常用的配置,可以避免页面上下滚动时动画反复触发。
  • viewport={{ amount: 0.8 }}: amount 是一个 0 到 1 之间的数字,表示元素自身高度的百分比。amount: 0.8 意味着只有当元素至少有 80% 的部分进入视口时,动画才会触发。

文件路径:src/App.tsx (优化版)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { motion } from "motion/react";

export default function App() {
return (
<div className="h-[200vh] pt-[50vh]">
<motion.div
className="w-40 h-40 bg-blue-500 rounded-lg mx-auto"
initial={{ opacity: 0, y: 100 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8 }}
// 关键配置让动画只播放一次
viewport={{ once: true }}
/>
</div>
);
}

本节小结

  • 核心 API: whileInView 是实现滚动触发动画最简单、最声明式的方式。
  • 一次性触发: viewport={{ once: true }} 是一个极其常用的优化,可以防止动画在用户滚动时重复播放。
  • 触发时机: viewport={{ amount: ... }} 允许我们精确控制动画在元素进入视口达到特定比例时才触发。

4.4. 本章小结

在本章中,我们成功地为动画架起了与用户沟通的桥梁。我们不再仅仅是动画的创作者,更是交互体验的设计师。通过掌握 Motion 强大的手势和视口感知系统,我们现在能够构建出真正动态、引人入胜的 Web 应用。

核心 while... 属性触发时机关键用途
whileHover鼠标悬停在元素上时提供即时的悬停反馈,增强可交互性。
whileTap用户点击(按下)元素时模拟按钮按下的物理感觉,提升点击体验。
drag附加属性,非 while启用元素的拖拽功能,是实现滑动卡片等交互的基础。
dragConstraintsdrag 配合将元素的拖拽行为限制在特定边界内。
whileInView元素滚动进入浏览器视口时实现高性能的“滚动即动画”效果,是现代网页设计的常用技巧。

我们已经学会了如何让动画“看”和“听”,并对用户的行为做出反应。在下一章,我们将学习 AnimatePresence 组件,这是 Motion 中一个里程碑式的概念,它将帮助我们完美地处理组件的“入场”与“离场”动画,解决 React 动画中最棘手的难题之一。


第五章. 入场与离场动画:AnimatePresence 完全指南

摘要: 在第二章,我们曾与 AnimatePresence 初次邂逅,了解了它如何“解锁”exit 动画。本章,我们将对它进行一次“完全解剖”。我们将从简单的组件挂载/卸载,深入到它最强大、最核心的应用场景——动态列表的增删动画。最后,我们还会学习其高级伴侣——Reorder 组件,轻松实现优雅的拖拽排序功能。


在本章中,我们将彻底征服 React 动画中最棘手的“存在性”问题:

  1. 首先,我们将再次巩固 核心概念,从更深层次理解 AnimatePresence 是如何通过 key 来追踪并管理组件生命周期的。
  2. 接着,我们将进入本章的核心——动态列表动画。我们将学习如何结合 useState.map(),为动态添加或移除的列表项赋予平滑的入场与离场效果。
  3. 最后,我们将学习一个令人兴奋的高级功能:使用 Reorder 组件,用极少的代码实现专业级的拖拽排序列表。

5.1. 核心概念:理解 AnimatePresence 的工作原理

我们回顾一下 AnimatePresence 的核心使命:在 React 组件真正从 DOM 树中移除之前,捕获到这个“即将离开”的状态,并为其播放 exit 动画提供时机。

深入原理
2025-10-18 09:30
M

我理解它的作用,但它内部到底是怎么工作的?为什么 key 那么重要?

E
expert

很好的问题。你可以把它想象成 React 自身 diffing 算法的一个“动画增强版”。

E
expert

AnimatePresence 的子元素发生变化时(比如从一个组件变为 null),它会对比新旧两棵“树”。它发现某个带特定 key 的组件在新树里消失了。

E
expert

此时,它并不会像 React 那样立即移除它,而是将这个“即将消失”的组件标记出来,暂时保留在 DOM 中,然后触发它身上的 exit 动画。key 是它在新旧两棵树之间精确识别“谁是谁”的唯一凭证。

M

所以没有 key,它就无法判断是同一个组件的更新,还是一个旧组件的离开和一个新组件的进入。

E
expert

完全正确!key 是身份的唯一标识。

5.2. 实战演练:实现组件的平滑挂载与卸载

作为热身,我们再次回顾单个组件淡入淡出的经典场景,确保我们对基础用法了然于胸。

文件路径:src/App.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import { AnimatePresence, motion } from "motion/react";
import { useState } from "react";

export default function App() {
const [isVisible, setIsVisible] = useState(true);

return (
<div className="w-40 h-40 flex flex-col items-center justify-between">
<div className="w-full h-24">
{/* 1. 使用 AnimatePresence 包裹条件渲染的组件 */}
<AnimatePresence>
{isVisible && (
<motion.div
// 2. 必须提供唯一的稳定的 key
key="interactive-box"
className="w-20 h-20 bg-blue-500 rounded-md"
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.5 }}
transition={{ type: "spring", stiffness: 300, damping: 20 }}
/>
)}
</AnimatePresence>
</div>

<motion.button
className="bg-slate-200 rounded-md py-2 px-4 text-sm font-bold"
onClick={() => setIsVisible(!isVisible)}
whileTap={{ scale: 0.95 }}
>
{isVisible ? "Hide" : "Show"}
</motion.button>
</div>
);
}

本节小结

  • 核心职责: AnimatePresence 的工作是在组件 即将 从 DOM 移除时介入,为其 exit 动画争取播放时间。
  • 关键依赖: 必须为其直接子组件提供一个稳定且唯一的 key 属性,这是其工作的基础。

5.3. 列表动画:结合 map 实现动态列表的增删动画

现在,我们进入本章的核心。单个元素的显隐动画相对简单,但 AnimatePresence 真正的威力体现在处理动态列表上。想象一下消息通知、待办事项列表或购物车,它们的项目都在动态地增加和减少。

我们的目标是:当一个新项目被添加到列表中时,它能优雅地“滑入”;当它被移除时,能平滑地“淡出”。

文件路径:src/App.tsx

img

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import { AnimatePresence, motion } from "motion/react";
import { useState } from "react";

let idCounter = 4; // 用于生成唯一 ID

export default function App() {
// 1. 使用 state 管理我们的列表数据
const [items, setItems] = useState([
{ id: 1, text: "Notification 1" },
{ id: 2, text: "Notification 2" },
{ id: 3, text: "Notification 3" },
]);

const addItem = () => {
setItems([
...items,
{ id: idCounter++, text: `Notification ${idCounter}` },
]);
};

const removeItem = (id: number) => {
setItems(items.filter((item) => item.id !== id));
};

return (
<div className="w-64">
<motion.button
className="bg-blue-500 text-white w-full py-2 px-4 mb-4 rounded-md font-bold"
onClick={addItem}
whileTap={{ scale: 0.95 }}
>
Add Notification
</motion.button>
<ul className="p-0 m-0 list-none">
{/* 2. AnimatePresence 包裹整个列表的映射 */}
<AnimatePresence>
{items.map((item) => (
<motion.li
// 3. key 必须是每个 item 唯一的稳定的标识符
key={item.id}
className="bg-slate-200 rounded-md p-3 mb-2 flex justify-between items-center"
initial={{ opacity: 0, y: 50, scale: 0.3 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, scale: 0.5, transition: { duration: 0.2 } }}
layout // layout 属性让列表在增删时其他元素能平滑移动到新位置
>
<span>{item.text}</span>
<motion.button
className="w-6 h-6 bg-white rounded-full flex items-center justify-center text-red-500 font-bold"
onClick={() => removeItem(item.id)}
whileTap={{ scale: 0.8 }}
>
&times;
</motion.button>
</motion.li>
))}
</AnimatePresence>
</ul>
</div>
);
}

我们在 motion.li 中添加了 layout 属性。这是一个小“魔法”,它能让列表中的其他元素在某个元素被移除时,自动、平滑地动画到它们的新位置,避免生硬的“跳跃”。我们将在第七章深入学习它。

本节小结

  • 包裹映射: 要为列表添加增删动画,应将 AnimatePresence 组件包裹在 .map() 调用的外部。
  • 稳定的 Key: 列表项的 key 必须是与数据绑定的唯一标识(如 item.id),绝对不能 使用数组索引 index 作为 key
  • 结合 layout: 在列表项上添加 layout 属性,可以极大地提升列表在增删项目时的视觉流畅度。

5.4. 列表重排:使用 Reorder 组件实现拖拽排序

处理列表的增删已经非常强大了,但 Motion 还为我们提供了一个更高级的武器:Reorder 组件,专门用于实现拖拽排序。

Reorder 由两个核心部分组成:

  • Reorder.Group: 作为容器,它接收一个 values 数组(你的数据源)和一个 onReorder 回调函数。
  • Reorder.Item: 作为可拖拽的列表项,它接收一个 value 属性,该值必须是 values 数组中的一项。

文件路径:src/App.tsx

img

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import { Reorder, useMotionValue } from "motion/react";
import { useState } from "react";

// 自定义 Reorder.Item 以添加样式和交互
function Item({ item }: { item: string }) {
const y = useMotionValue(0); // 用于创建拖拽时的垂直偏移视觉效果

return (
<Reorder.Item
value={item}
style={{ y }}
className="bg-white p-4 my-2 rounded-lg shadow-md cursor-grab"
whileTap={{ cursor: "grabbing", scale: 1.05 }}
>
<span>{item}</span>
</Reorder.Item>
);
}

export default function App() {
const [items, setItems] = useState([
"🍎 Apple",
"🍌 Banana",
"🍒 Cherry",
"🍇 Grapes",
]);

return (
<div className="w-64">
<h2 className="text-xl font-bold text-center mb-4">Fruit Basket</h2>
{/* 1. Reorder.Group 包裹列表 */}
<Reorder.Group
// 2. 轴向和数据绑定
axis="y"
values={items}
// 3. 关键当重排发生时用新顺序更新我们的 state
onReorder={setItems}
className="p-0 m-0 list-none"
>
{items.map((item) => (
// 4. 为每个 item 渲染一个 Reorder.Item
<Item key={item} item={item} />
))}
</Reorder.Group>
</div>
);
}

Reorder 组件的内部机制非常智能。当你拖拽一个 Reorder.Item 时,它会自动计算并应用 transform 来平滑地移动其他列表项,创造出流畅的排序体验。我们唯一要做的,就是在 onReorder 回调中,用它提供给我们的新顺序数组来更新我们自己的状态。

本节小结

  • 专用组件: 使用 Reorder.GroupReorder.Item 来实现拖拽排序功能。
  • 数据驱动: Reorder.Group 通过 values 属性接收数据,并通过 onReorder 回调函数返回新的数据顺序。
  • 状态更新: 我们的核心职责是在 onReorder 回调中调用状态更新函数(如 setItems),将新的顺序持久化。

5.5. 本章小结

在本章中,我们从概念到实战,彻底征服了 AnimatePresence。我们不仅深化了对其工作原理的理解,更掌握了它在处理动态列表增删这一核心场景下的应用。最后,我们还学习了 Reorder 组件,为我们的技能库增添了强大的拖拽排序能力。

核心组件关键属性 / 用法解决的核心问题
AnimatePresence包裹条件渲染或 .map() 的组件为 React 组件提供优雅的 入场离场 动画能力。
key附加在 AnimatePresence 的直接子组件上作为组件的唯一身份标识,是 AnimatePresence 能够追踪组件生命周期的基石。
Reorder.Groupvalues, onReorder管理整个可排序列表的状态,并在排序结束后通知我们更新数据。
Reorder.Itemvalue代表一个可拖拽的列表项,其 valuevalues 数组中的数据项对应。

现在,我们已经能够处理绝大多数围绕组件“存在性”的动画挑战了。在下一章,我们将转向一个全新的、同样强大的领域:数据驱动动画。我们将学习 Motion Values,探索如何将动画与滚动、鼠标位置等连续变化的数据源进行绑定,创造出更具互动性和表现力的视觉效果。


第六章. 数据驱动动画:Motion Values 的强大功能

摘要: 至此,我们已经掌握了如何创建预设的、响应用户手势的动画。本章,我们将解锁 Framer Motion 中一个最具创造力的概念:Motion Values。我们将学习如何创建独立于 React 渲染循环的、可被追踪的动画状态,并利用 useTransform, useScroll 等强大的 Hooks,将这些状态与用户的连续输入(如滚动、鼠标移动)绑定,创造出令人惊叹的、真正的数据驱动型交互效果。


在本章中,我们将开启一扇通往高级动画世界的大门:

  1. 首先,我们将理解 核心概念 MotionValue,特别是它为何能在处理高频更新(如滚动)时,提供远超 useState 的性能。
  2. 接着,我们将学习 值转换的艺术,使用 useTransform Hook 将一个输入范围(如滚动进度 0-1)映射到一个完全不同的输出范围(如元素宽度 0%-100%)。
  3. 然后,我们将深入 滚动驱动动画,利用 useScroll Hook 精准捕获页面或特定元素的滚动进度,并将其作为动画的驱动源。
  4. 最后,我们将为我们的 MotionValue 注入 弹簧物理学,通过 useSpring Hook 让生硬的数值变化变得平滑、自然。

6.1. 核心概念:什么是 MotionValue?

MotionValue 是 Framer Motion 内部的一个特殊对象,用于追踪单个数字或颜色值的 状态速度。它与 React 的 useState 有一个根本性的区别,这也是它性能卓越的关键所在。

性能对比:useState vs MotionValue
2025-10-18 11:00
M

我可以用 useState 来追踪滚动位置,然后在 style 里更新元素的 transform,这和 MotionValue 有什么不一样?

E
expert

这是一个非常核心的问题。当你使用 useState 时,每一次滚动事件(这会非常频繁地触发)都会调用 setState

E
expert

setState 会触发整个组件(乃至其子组件)的 重新渲染 (re-render)。在高频更新下,这会给 React 带来巨大的性能负担,导致动画卡顿、掉帧。

E
expert

MotionValue 则完全绕过了 React 的渲染循环。当你把它链接到一个 motion 组件的 style 属性上时,Motion 会在底层直接、高效地更新 DOM 元素的样式。整个过程不会触发任何一次 React 的 re-render

M

明白了!MotionValue 是一种“直达”DOM 的、专为动画优化的状态管理机制。

要创建一个 MotionValue,我们使用 useMotionValue Hook。

1
2
3
import { useMotionValue } from "motion/react";

const x = useMotionValue(0); // 创建一个初始值为 0 的 MotionValue

6.2. 基础应用:useMotionValuestyle 属性的联动

创建 MotionValue 后,最直接的用法就是将它传递给 motion 组件的 style 属性。

文件路径:src/App.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { motion, useMotionValue } from "motion/react";

export default function App() {
// 1. 创建一个 MotionValue,初始值为 0
const x = useMotionValue(0);

// 在这个例子中,我们可以在某个事件中手动更新它
// x.set(100); // 命令式地设置新值

return (
<div>
{/* // 2. 将 MotionValue 直接传入 style 对象
// // 当 x 的值改变时,这里的
translateX 会被直接更新,而无需组件重渲染 */}
<motion.div
className="w-20 h-20 bg-blue-500 rounded-full"
style={{ x }} // { x } { x: x } 的简写
/>
<button onClick={() => x.set(100)}>Move</button>
</div>
);
}

虽然我们可以通过 .set() 方法手动更新 MotionValue,但它真正的威力在于与其他 Hooks 结合,自动地响应外部数据源。

本节小结

  • 核心价值: MotionValue 是一个独立于 React 渲染循环的、为动画性能而优化的状态容器。
  • 创建方式: 使用 useMotionValue(initialValue) Hook。
  • 基础用法: 直接将其传入 motion 组件的 style 属性,以实现高效的样式更新。

6.3. 值转换的艺术:使用 useTransform 创造复杂联动

useTransformMotion Values 生态中最强大的 Hook 之一。它允许我们创建一个 派生的 MotionValue,这个新值会根据一个输入 MotionValue 的变化而自动、响应式地改变。

它的核心功能是 范围映射

文件路径:src/App.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import { motion, useMotionValue, useTransform } from "motion/react";

export default function App() {
const x = useMotionValue(0);
// 1. 创建一个派生 MotionValue
// 当 x 的值在 [-200, 200] 范围变化时... 从小变大
const scale = useTransform(x, [-200, 0, 200], [0.5, 1, 0.5]);
// 当 x 的值在 [-200, 200] 范围变化时... 从左到右
const rotate = useTransform(x, [-200, 200], [-45, 45]);
// 当 x 的值在 [-200, 200] 范围变化时... 从红到蓝到绿
const backgroundColor = useTransform(
x,
[-200, 0, 200],
["#ff0000", "#0000ff", "#00ff00"]
);

return (
<div className="w-96 h-96 bg-slate-200 rounded-lg flex items-center justify-center mx-auto">
<motion.div
className="w-24 h-24 rounded-full bg-blue-500"
// 2. 将派生的 MotionValue 应用于样式
style={{ x, scale, rotate, backgroundColor }}
drag="x" // 允许水平拖拽
dragConstraints={{ left: -200, right: 200 }} // 设置拖拽边界
whileTap={{ cursor: "grabbing" }}
/>
</div>
);
}

在这个例子中,我们只驱动了一个 MotionValue——x(通过拖拽)。而 scale, rotatebackgroundColor 都成为了 x 的“追随者”,根据预设的映射关系自动更新。这就是数据驱动动画的魅力。

本节小结

  • 核心功能: useTransform 根据一个输入的 MotionValue 和一个范围映射关系,创建一个新的、派生的 MotionValue
  • 语法: useTransform(inputValue, inputRange, outputRange)
  • 强大之处: 实现了动画属性之间的复杂解耦和联动,只需改变一个源头,就能驱动一系列的视觉变化。

6.4. 滚动驱动动画:useScroll 完全解析

useScroll Hook 是 MotionValue 最常见的“数据源”之一。它能为我们提供关于页面或特定元素滚动位置的实时 MotionValue

useScroll() 返回一系列 MotionValue,其中最有用的是:

  • scrollYProgress: 页面垂直滚动的进度,值从 0 (页面顶部) 变化到 1 (页面底部)。
  • scrollXProgress: 页面水平滚动的进度。

让我们用它来创建一个经典的页面滚动进度条。

文件路径:src/App.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import { motion, useScroll, useSpring } from "motion/react";

export default function App() {
// 1. 调用 useScroll 获取滚动进度
const { scrollYProgress } = useScroll();

// 2. (可选)使用 useSpring 为进度条添加平滑效果
const scaleX = useSpring(scrollYProgress, {
stiffness: 100,
damping: 30,
restDelta: 0.001
});

return (
<>
{/* 3. 创建进度条元素 */}
<motion.div
className="fixed top-0 left-0 right-0 h-2 bg-blue-500 origin-left"
// 4. scaleX MotionValue 绑定到样式上
style={{ scaleX }}
/>
<div className="h-[200vh] p-8">
<h1 className="text-3xl font-bold">Scroll Down</h1>
</div>
</>
);
}

追踪特定元素

useScroll 还可以通过 target 选项来追踪特定元素的滚动进度,而不是整个页面。这对于实现视差效果或元素进入视口时的复杂动画非常有用。

本节小结

  • 核心 Hook: useScroll 是获取滚动位置 MotionValue 的标准方式。
  • 关键值: scrollYProgress (从 0 到 1) 是最常用的返回值,非常适合作为 useTransform 的输入。
  • 应用场景: 页面进度指示器、视差效果、基于滚动位置的复杂元素动画。

6.5. 弹簧物理学:useSpring 的应用

我们从 useScroll 得到的 scrollYProgress 是一个原始的、瞬时变化的值。有时我们希望动画能更平滑地“追随”这个值,而不是生硬地跳变。useSpring Hook 正是为此而生。

useSpring 接收一个 MotionValue 和一个弹簧配置对象,然后返回一个新的、应用了弹簧物理效果的 MotionValue

让我们看一个自定义鼠标跟随光标的例子,来体会 useSpring 的“平滑”魔力。

文件路径:src/App.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import { motion, useMotionValue, useSpring } from "motion/react";
import { useEffect } from "react";

export default function App() {
// 1. 创建两个 MotionValue 来存储原始的鼠标坐标
const mouse = {
x: useMotionValue(0),
y: useMotionValue(0),
};

// 2. 为每个坐标创建一个应用了 spring 效果的 MotionValue
const smoothOptions = { stiffness: 300, damping: 20, mass: 0.5 };
const smoothMouse = {
x: useSpring(mouse.x, smoothOptions),
y: useSpring(mouse.y, smoothOptions),
};

useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
// 3. 在事件回调中,更新原始的 MotionValue
mouse.x.set(e.clientX);
mouse.y.set(e.clientY);
};

window.addEventListener("mousemove", handleMouseMove);
return () => {
window.removeEventListener("mousemove", handleMouseMove);
};
}, []);

return (
<div className="w-full h-screen">
<motion.div
className="w-12 h-12 bg-blue-500 rounded-full fixed"
// 4. 将平滑后的 MotionValue 应用于样式
// 这里需要将坐标居中
style={{
translateX: smoothMouse.x,
translateY: smoothMouse.y,
x: "-50%",
y: "-50%",
}}
/>
</div>
);
}

在这个例子中,光标的移动是生硬的,但蓝色的圆点则会以一种平滑、自然的弹簧动态来“追赶”光标,体验截然不同。

本节小结

  • 核心作用: useSpring 为一个 MotionValue 添加了物理平滑效果,使其值的变化不再是瞬时的。
  • 工作流程: 创建一个原始 MotionValue,用 useSpring 将其包裹,然后在事件中更新原始值,在样式中使用平滑后的值。
  • 应用场景: 鼠标跟随、滚动驱动的平滑动画、以及任何需要将突变数据转化为自然动态的场景。

6.6. 本章小结

在本章中,我们掌握了 Framer Motion 中最具创造力的核心概念——Motion Values。我们学会了如何摆脱 React 的渲染循环,创建高性能的数据驱动型动画,这标志着我们从“动画实现者”向“交互设计师”的思维转变。

核心 Hook返回值 / 作用关键用途
useMotionValueMotionValue创建一个独立于 React 渲染循环的、可追踪的动画状态。
useTransformMotionValue将一个输入 MotionValue 的范围映射到一个输出范围,创建派生动画。
useScroll{ scrollYProgress, ... }MotionValue 的形式获取页面或元素的滚动进度。
useSpringMotionValue为一个输入的 MotionValue 添加弹簧物理效果,使其变化更平滑。

我们现在拥有的工具,足以构建出在现代网站上看到的几乎所有高级滚动和交互效果。在下一章,我们将探索另一个令人惊叹的领域:布局动画。我们将学习如何使用 layout 属性,让组件在尺寸、位置或顺序发生改变时,自动地、神奇地生成平滑的过渡动画。


第七章. 布局动画:从位置变换到共享元素过渡

摘要: 欢迎来到 Framer Motion 中最令人惊叹、最具“魔力”的领域。在本章中,我们将学习布局动画。我们将探索如何仅用一个 layout 属性,就让组件在尺寸或位置改变时自动产生平滑的动画。更进一步,我们将掌握 layoutId 的强大功能,实现跨组件的“共享元素过渡”效果,这是现代 UI 设计中极具表现力的一种高级动画。


在本章中,我们将学会如何驾驭 DOM 中元素的流动与变换:

  1. 首先,我们将体验 layout 属性的自动魔法,亲眼见证一个单词如何将生硬的布局跳变转化为流畅的动画过渡。
  2. 接着,我们将深入本章的核心——共享元素过渡。利用 layoutId,我们将连接两个在视觉上独立、但在概念上统一的元素,创造出无缝切换的惊艳效果。
  3. 最后,我们将学习 LayoutGroup 的作用,了解如何用它来协调复杂的布局动画组,避免视觉闪烁,确保动画的完美呈现。

7.1. 自动魔法:layout 属性入门

在常规的 Web 开发中,布局的改变通常是瞬时且生硬的。比如,当你改变一个 flex 容器的 justify-content 属性,或者当一个列表项被移除导致其他项向上移动时,这些变化都是“跳跃”完成的。手动为这些布局变化添加平滑的过渡动画,通常非常复杂和繁琐。

Framer Motion 提供了一个极其优雅的解决方案:layout 属性。

你只需在 motion 组件上添加 layout 这个单词,它就会自动:

  1. 观察:实时监测该元素在 DOM 中的尺寸和位置。
  2. 响应:当其尺寸或位置因任何原因(如样式变化、兄弟元素增删等)即将改变时。
  3. 动画:自动计算出新旧位置/尺寸之间的差异,并使用高效的 transform 属性来平滑地动画到新状态。

让我们来看一个开关对齐方式的例子。

文件路径:src/App.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { motion } from "motion/react";
import { useState } from "react";

export default function App() {
const [isToggled, setIsToggled] = useState(false);

return (
<div
// 点击容器时切换状态
onClick={() => setIsToggled(!isToggled)}
className={`w-40 h-12 rounded-full flex items-center p-1.5 cursor-pointer transition-colors ${
isToggled ? "bg-blue-500 justify-end" : "bg-gray-300 justify-start"
}`}
>
<motion.div
className="w-9 h-9 bg-white rounded-full shadow-md"
// 关键所在添加 layout 属性
layout
// (可选) layout 动画自定义 transition
transition={{ type: "spring", stiffness: 300, damping: 20 }}
/>
</div>
);
}

现在,每次点击灰色容器,蓝色小球都会以流畅的弹簧动画在左右两端之间移动,而不是生硬地跳跃。我们仅仅添加了一个 layout 属性,就完成了这一切。

本节小结

  • 核心价值: layout 属性可以自动地为元素的尺寸和位置变化添加动画,将生硬的布局跳变转化为平滑的过渡。
  • 工作原理: 它通过高效地动画 transform 属性来实现,性能极佳。
  • 自定义: layout 动画同样可以通过 transition 属性来定制其动态行为(如 type, duration 等)。

7.2. 共享元素:使用 layoutId 实现跨组件动画

layoutIdlayout 功能的延伸,也是 Framer Motion 中最令人印象深刻的特性之一。它能让我们实现所谓的“共享元素过渡” (Shared Element Transition)。

想象一下在应用中常见的场景:你点击一个相册中的小图(缩略图),这张小图会平滑地放大并移动到屏幕中央,变成一张大图。在这个过程中,用户的感觉是“同一个元素”在变换,而不是一个小图的消失和一个大图的出现。

layoutId 正是实现这种效果的钥匙。它的工作机制如下:

  1. 当一个带有 layoutIdmotion 组件即将 离开 DOM 时…
  2. Framer Motion 会检查是否有另一个带有 相同 layoutIdmotion 组件即将 进入 DOM。
  3. 如果匹配成功,它就不会执行各自独立的 exitinitial 动画,而是创建一个单一的、平滑的 transform 动画,将元素的视觉形态从离开的组件“变形”到进入的组件。

让我们通过一个卡片展开的经典例子来实践它。

文件路径:src/App.tsx

img

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
import { AnimatePresence, motion } from "motion/react";
import { useState } from "react";

const imageUrl = "https://picsum.photos/400/300";

function Card({ id, onSelect }: { id: string; onSelect: (id: string) => void }) {
return (
<motion.img
// 1. 在缩略图上设置 layoutId
layoutId={id}
onClick={() => onSelect(id)}
src={imageUrl}
alt="thumbnail"
className="w-32 h-20 object-cover rounded-lg cursor-pointer"
/>
);
}

function ExpandedCard({ id, onDeselect }: { id: string; onDeselect: () => void }) {
return (
// 使用一个遮罩层
<motion.div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center"
onClick={onDeselect}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
>
<motion.img
// 2. 在展开图上设置相同的 layoutId
layoutId={id}
src={imageUrl}
alt="expanded"
className="w-80 h-48 object-cover rounded-xl"
/>
</motion.div>
);
}

export default function App() {
const [selectedId, setSelectedId] = useState<string | null>(null);

return (
<div className="flex gap-4">
{["card-1"].map((id) => (
<Card key={id} id={id} onSelect={setSelectedId} />
))}

{/* 3. 使用 AnimatePresence 来管理展开卡片的出入场 */}
<AnimatePresence>
{selectedId && (
<ExpandedCard id={selectedId} onDeselect={() => setSelectedId(null)} />
)}
</AnimatePresence>
</div>
);
}

现在,当你点击任何一个蓝色卡片时,它都会平滑地放大到屏幕中央。再次点击遮罩层,它又会平滑地缩小并回到原来的位置。整个过程如行云流水,而我们所做的仅仅是让两个不同的组件共享了同一个 layoutId

本节小结

  • 核心作用: layoutId 用于在两个或多个不同的 motion 组件之间创建视觉上的连接,实现平滑的“变形”过渡。
  • 工作机制: 它通过匹配即将离开和即将进入的、拥有相同 layoutId 的组件来触发。
  • 必要配合: 共享元素过渡通常涉及组件的挂载和卸载,因此 必须AnimatePresence 配合使用。

7.3. 统一管理:LayoutGroup 的作用

在某些复杂的场景下,尤其是当 layoutId 过渡发生在组件树中相隔较远的部分时,Framer Motion 可能需要一些帮助来理解哪些布局动画应该被视为一个整体来协调。

此外,当一个共享元素在过渡时,可能会引起其他非共享元素的布局变化(比如列表中的其他项需要移动)。LayoutGroup 组件可以确保所有这些相关的布局变化都能被捕获并平滑地动画。

简单来说,LayoutGroup 的作用是:
向 Framer Motion 声明:“在这个组件包裹范围内的所有 layout 动画,都请作为一个整体来计算和执行。”

它能有效地防止在复杂过渡中可能出现的视觉闪烁或不协调的问题。

虽然在简单的例子中 LayoutGroup 可能不是必需的,但在构建复杂的、包含多重布局动画的界面时,将相关的组件包裹在一个 LayoutGroup 中是一个非常好的健壮性实践。

文件路径:src/App.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import React, { useState } from "react";
import { motion, LayoutGroup } from "motion/react";

const App: React.FC = () => {
const [items, setItems] = useState([
{ id: "1", title: "项目 A", color: "bg-red-400" },
{ id: "2", title: "项目 B", color: "bg-teal-400" },
{ id: "3", title: "项目 C", color: "bg-blue-400" },
]);

const moveToTop = (id: string) => {
setItems((prev) => {
const item = prev.find((i) => i.id === id)!;
return [item, ...prev.filter((i) => i.id !== id)];
});
};

return (
<LayoutGroup>
<div className="p-5 max-w-sm">
<h2 className="text-xl font-semibold mb-4">复杂布局动画协调示例</h2>

{/* 列表区域 */}
<div className="mb-5">
{items.map((item) => (
<motion.div
key={item.id}
layoutId={item.id}
layout
onClick={() => moveToTop(item.id)}
className={`${item.color} p-4 my-2.5 rounded-lg cursor-pointer text-white`}
>
{item.title}
</motion.div>
))}
</div>
</div>
</LayoutGroup>
);
};

export default App;

本节小结

  • 核心作用: LayoutGroup 用于将一组相关的 layoutlayoutId 动画声明为一个统一的批次,以确保它们被协调地计算和执行。
  • 使用场景: 在复杂的布局过渡中,特别是当 layoutId 动画跨越多个父组件或引起其他元素连锁布局变化时,使用 LayoutGroup 可以提升动画的稳定性和流畅性。
  • 最佳实践: 当你遇到 layout 动画出现闪烁或行为不一致时,首先应该尝试用 LayoutGroup 将相关组件包裹起来。

7.4. 本章小结

在本章中,我们掌握了 Framer Motion 中最具变革性的 layout 动画系统。我们学会了如何将静态的布局变化转化为动态的视觉盛宴,这是提升现代 Web 应用用户体验的关键一步。

核心 API描述关键用途
layout单个属性,用于自动动画化元素的尺寸和位置变化。将生硬的布局“跳变”转化为平滑的过渡动画。
layoutId一个字符串 ID,用于在不同组件间建立视觉连接。实现“共享元素过渡”效果,如缩略图到大图的无缝放大。
LayoutGroup一个包裹组件,用于统一管理一组布局动画。在复杂场景中协调多个 layout 动画,防止视觉闪烁,保证过渡的稳定性。