先把话说清楚,这篇文章是 motion 基础用法的第二篇。上一篇里你大概率已经写过类似<motion.div animate={{ x: 100 }}>的效果,也知道了 initial、animate、transition 这几个核心属性分别管什么。这篇我要把范围稍微拉大一点,聊的是那些让动画之间产生"关系"的 API:variants 的编排、离场动画、手势交互、滚动驱动、布局过渡。用到的库依然是 motion,新版统一从motion/react导入,老项目里叫 framer-motion 的写法也基本通用。
我打算用一个贯穿全文的场景来串起所有 API——一个卡片列表页面。页面加载时卡片依次进场,鼠标悬停有轻微放大,按下去有按压缩放,卡片支持横向拖动,往下滚动时标题有视差效果,点开卡片弹窗时背景从卡片位置展开,删除卡片时其余卡片自动补位。这个场景不算复杂,但几乎把 motion 基础用法的核心 API 全覆盖了,而且每一步你都能直接抄回去改。
如果你是第一次接触 motion,我建议先把基础属性补一下再回来看这篇;如果你已经用基础属性做过一两个页面,那这篇正好能帮你把动画从"单个元素动一下"升级成"整页有节奏地动"。下面的内容不是文档翻译,是我实际踩坑之后留下来的那部分。
1. variants:把动画从单点触发升级成整套编排
1.1 variants 到底是什么,和直接写 animate 有什么区别
直接写animate={{ opacity: 1 }}的时候,动画状态是散落在组件属性里的。一旦页面里有多个元素需要按顺序、按分组进场,这种写法会迅速失控。variants 的思路很简单:把所有动画状态集中成一个具名对象,然后通过状态名去切换。
import { motion } from "motion/react"; const cardVariants = { hidden: { opacity: 0, y: 24 }, visible: { opacity: 1, y: 0, transition: { duration: 0.5, ease: "easeOut" } } }; function Card() { return ( <motion.div variants={cardVariants} initial="hidden" animate="visible"> {/* 卡片内容 */} </motion.div> ); }注意这里initial="hidden"和animate="visible"传的是状态名,不是具体的值。motion 会去variants对象里找对应的状态来套用。这个设计最大的价值在于:你可以在多个组件里维护同一套状态命名体系,让它们步调一致。
我自己的习惯是:只要一个页面上动画状态的组合超过三种以上,就优先建 variants,而不是在 JSX 里堆匿名对象。尤其是有 hover、tap、focus 多种交互并存的时候,variants 能让你一眼看清每个组件的全部动画分支,而不是在 JSX 里翻一堆whileHover={{ scale: 1.05 }}。命名本身也很重要,我通常用 hidden、visible 这种"过程语义"而不是 fade、slide 这种"动作语义",因为一个状态往往同时包含位移、透明度、缩放多种变化。
1.2 父子组件联动:一次触发,全家响应
variants 真正厉害的地方在父子联动。父组件进入某个状态时,子组件如果定义了同名的 variants 状态,会自动跟着切换,不需要你手动往子组件里塞 props 或者用 context 传递状态。
const listVariants = { hidden: {}, visible: { transition: { staggerChildren: 0.08, delayChildren: 0.2 } } };这段代码写在父容器上,子元素只要各自声明了variants={itemVariants},并且initial="hidden" animate="visible",它们就会按照父级的编排批量进场。staggerChildren控制每个子级之间的间隔时间,delayChildren是整个批次开始前先等待的时长。这种机制避免了在业务代码里写一堆setTimeout去手动错开动画,维护起来轻松很多。
实际开发中还有一个容易被忽略的用法:variants的值可以是一个函数,接收custom属性传入的额外数据。
const itemVariants = { hidden: { x: -20, opacity: 0 }, visible: (index) => ({ x: 0, opacity: 1, transition: { delay: index * 0.1 } }) }; // 使用 <motion.div custom={index} variants={itemVariants} initial="hidden" animate="visible" />这个custom传参在渲染列表时特别有用,比如每张卡片按自己的索引获得不同的延迟,而不是全局统一 stagger。注意函数式 variants 返回的 transition 会和父级 stagger 叠加,如果你两边都写了 delay,最终延迟是两者相加,调试的时候容易懵,我后面会再讲这个坑。
1.3 stagger 编排实战:列表依次进入
回到卡片列表场景。你希望页面加载后,顶部标题先出现,然后下面的四张卡片每张相隔 80ms 依次进场。用 variants 写就是:
const pageVariants = { hidden: {}, visible: { transition: { staggerChildren: 0.08 } } }; const titleVariants = { hidden: { opacity: 0, y: 16 }, visible: { opacity: 1, y: 0 } }; const cardItemVariants = { hidden: { opacity: 0, y: 24, scale: 0.95 }, visible: { opacity: 1, y: 0, scale: 1 } }; <motion.div variants={pageVariants} initial="hidden" animate="visible"> <motion.h2 variants={titleVariants}>卡片列表</motion.h2> <motion.div variants={pageVariants}> {cards.map((card) => ( <motion.div key={card.id} variants={cardItemVariants} /> ))} </motion.div> </motion.div>你甚至可以让标题也作为 stagger 队列里的第一个成员,它会和卡片形成连续节奏,而不是各动各的。我在实操中会把整个页面的进场动画做成一个统一的 variants 树,页面整体就是一个状态机。这样做的好处是,当你要做路由切换时,只需要在页面顶层切换animate的状态名,所有子元素自动跟着走。
提示:stagger 的间隔单位是秒,0.08 是 80ms,这个值不是越小越好。过快的 stagger 会让人感觉页面没反应,过慢则显得拖沓。列表超过 6 项时,我会把间隔从 0.08 调到 0.05,并让
delayChildren承担最开始的停顿。
2. AnimatePresence:把"离场动画"从不可能变成标配
2.1 为什么要用 AnimatePresence,它解决了什么
React 卸载组件是瞬间的。你在 JSX 里写{show && <motion.div exit={{ opacity: 0 }} />}时,如果不做任何处理,退出动画根本不会播放——组件在下一帧就没了。AnimatePresence 的存在就是为了解决这个"离场"问题:它会把即将卸载的子元素先扣留一段时间,等 exit 动画播完,再把元素真正从 DOM 里移除。
import { AnimatePresence, motion } from "motion/react"; function Toggle() { const [show, setShow] = useState(true); return ( <> <button onClick={() => setShow((s) => !s)}>切换</button> <AnimatePresence> {show && ( <motion.div key="panel" initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 16 }} > 面板内容 </motion.div> )} </AnimatePresence> </> ); }这里 key 必须存在且稳定。AnimatePresence 是通过 key 的变化判断哪个子元素"离开"了,没有 key 或者 key 不变,它没办法精确追踪组件实例。这也是我用 AnimatePresence 时第一个检查的点。
实际项目中我常常把弹窗、下拉菜单、Toast 提示这类临时元素包在 AnimatePresence 里,让它们退场时有个自然的淡出或者下滑效果。很多组件库的消失动画其实就是这样实现的。
2.2 mode 三种模式怎么选
AnimatePresence 的mode属性控制进场元素和退场元素在时间上的重叠方式。
mode="sync":默认值,进场和退场同时进行。适合大多数场景,但两个元素同时在场的瞬间可能造成布局跳动。mode="wait":先等退场动画完全结束,再让进场元素入场。适合 Tab 切换、图片轮播这类需要严格时序的场景,缺点是如果退场动画太长,交互响应会显得慢。mode="popLayout":离场元素被设为绝对定位脱离文档流,其余内容不受影响。这套机制非常适合列表删除的场合,因为它在离场动画期间不会挤压剩余元素的位置。
我的经验是:Tab 切换内容比较独立时优先用wait,视觉上"干净利落,不重叠",但要注意把退场时长控制在 200ms 左右,否则点击 Tab 会有明显的迟滞感。列表删除则直接用popLayout,配合 layout 动画能让剩余项平滑补位。
<AnimatePresence mode="wait"> {activeTab === "home" ? ( <motion.div key="home" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}> 首页内容 </motion.div> ) : ( <motion.div key="about" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}> 关于内容 </motion.div> )} </AnimatePresence>2.3 增删列表与布局动画的组合用法
卡片列表场景里,用户删除一张卡片后,剩下的卡片希望能平滑地补上空位,而不是瞬间跳上去。这个效果要用 AnimatePresence 配合layout属性。
<AnimatePresence> {cards.map((card) => ( <motion.div key={card.id} layout initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.9 }} transition={{ type: "spring", stiffness: 300, damping: 30 }} > {/* 卡片内容 */} </motion.div> ))} </AnimatePresence>这里 motion 会给每个元素标记位置,任何元素被移除时,其他元素会因为layout自动补间到新位置。删除的一瞬间,被删卡片会先缩小淡出,而下方卡片平滑上移,整体手感非常接近原生应用。
提示:列表项一旦多了,不要给每一项都加
layout。对 20 个以上的可排序列表,layout 的节点测量会让页面有明显的卡顿。解决办法是只给首尾若干项加 layout,或者用layout="position"跳过尺寸测量只做位移补间。
3. 手势动画:让交互有"手感"
3.1 whileHover / whileTap / whileFocus 的实战写法
motion 把常用手势直接做成了组件属性:whileHover、whileTap、whileFocus、whileDrag。你只需要在对应手势发生时传入目标动画状态即可,不需要自己监听 mouseenter、mousedown 再手动改样式。
<motion.button whileHover={{ scale: 1.04 }} whileTap={{ scale: 0.96 }} transition={{ type: "spring", stiffness: 300, damping: 20 }} > 主要按钮 </motion.button>这里面有个细节:transition是全局的,hover 和 tap 都会用它。如果 hover 希望柔一点、tap 希望干脆一点,没关系,可以在whileTap的状态对象里嵌套自己的 transition。
whileTap={{ scale: 0.95, transition: { type: "spring", stiffness: 500, damping: 30 } }}而且手势属性同样支持 variants 的状态名,不非得写对象。比如在 variants 里定义好 hover、tap 状态,组件只写whileHover="hover" whileTap="tap",状态逻辑全集中在 variants 里。这个写法在组件样式分支多的时候特别清楚。
在卡片场景里,我给卡片配置了whileHover={{ y: -4 }}和whileTap={{ scale: 0.98 }}。悬停时卡片微微上浮,点击时轻微内缩,配合盒子阴影,用户马上能感觉到卡片"可交互"。这种手感的建立主要靠位移和阴影,不是靠颜色变化。
3.2 拖拽:drag、dragConstraints、dragElastic
拖拽属性是 motion 的拿手好戏:drag设置可拖拽的方向,dragConstraints设置边界,dragElastic控制越界后的弹性,dragSnapToOrigin让元素松手后回到原位。
<motion.div drag="x" dragConstraints={{ left: 0, right: 320 }} dragElastic={0.2} dragSnapToOrigin > 拖我 </motion.div>dragConstraints还可以传一个 ref 对象,让元素被限制在某个容器范围内:
const containerRef = useRef(null); <motion.div ref={containerRef}> <motion.div drag dragConstraints={containerRef} /> </motion.div>dragConstraints 接收 ref 时,motion 会在拖拽开始前计算出容器的实际边界,元素只能在容器内部移动。这比手写固定像素值要灵活得多,配合响应式布局也不会跑偏。
实际开发里我经常用这个 API 做移动端轮播、抽屉面板、下拉刷新这类交互。在卡片列表场景,我让卡片支持横向拖动一定距离,超过阈值松手后会先弹出一段删除确认再回弹,本质上就是dragSnapToOrigin加onDragEnd判断偏移量。
<motion.div drag="x" dragElastic={0.4} dragSnapToOrigin onDragEnd={(event, info) => { if (Math.abs(info.offset.x) > 100) { // 触发删除确认逻辑 } }} >有个坑必须提醒:开启 drag 的容器内部如果还有可点击按钮,点击事件可能会被拖拽判定吃掉的概率很高。onClick在拖拽结束后依然会触发,因为它只看 pointerup 的位置。如果拖拽了 60px 还触发 click,交互就乱了。我的通用解法是在 onClick 里判断info.offset,如果没有 drag 信息,就正常执行;如果有 drag 偏移量超过阈值,就忽略这次点击。
3.3 useDragControls:把拖拽触发权交给其他元素
有些场景不想让元素整个区域都能拖拽,比如一个卡片内部有表单、按钮,只有按住卡片头部才能拖动。用useDragControls可以把拖拽触发权单独拆出来。
import { useDragControls } from "motion/react"; function DraggableCard() { const controls = useDragControls(); return ( <motion.div drag="x" dragControls={controls}> <motion.div onPointerDown={(e) => controls.start(e)} className="handle"> 按住这里拖动 </motion.div> <button>正常点击按钮</button> </motion.div> ); }这里的关键是把onPointerDown绑在拖拽手柄上,而不是整个元素上。这样卡片内部的按钮、链接不会和拖拽冲突,用户只能通过手柄区触发拖动。controls.start接收原生 PointerEvent,motion 内部会接着处理后续的 pointermove 和 pointerup。
4. 滚动驱动动画:让页面跟随滚轮平滑变化
4.1 useScroll 与 useTransform 的基础闭环
滚动动画的核心是两个 API:useScroll生成 MotionValue(滚动进度),useTransform把进度值映射成你需要的样式值。
import { useScroll, useTransform, motion } from "motion/react"; function Page() { const { scrollYProgress } = useScroll(); const opacity = useTransform(scrollYProgress, [0, 0.5], [1, 0]); const scale = useTransform(scrollYProgress, [0.2, 0.5], [1, 1.2]); return ( <motion.div style={{ opacity, scale }}> 内容 </motion.div> ); }scrollYProgress的值范围是 0 到 1,0 表示页面在顶部,1 表示滚动到底部。useTransform做的就是"输入区间"到"输出区间"的映射:输入到达 0.5 时,opacity 已经线性降到 0,scale 则到 0.2 才开始从 1 走向 1.2。
这条链路最大的优势是:MotionValue 是被动画系统直接消费的,不需要通过 React state 中转。如果你把滚动值塞进useState,每次滚动都会触发组件重新渲染,页面会非常卡。motion 的方式是直接操作 style,不会引发重渲染。
它的应用场景非常多:阅读进度条、头部导航栏在滚动后收缩、背景视差、图片缩放进入视野。我常用它做页头半透明效果:滚动 300px 内,页面标题的透明度从 0 渐变到 1,同时向下位移 20px。
const { scrollY } = useScroll(); const headerOpacity = useTransform(scrollY, [0, 300], [0, 1]); const headerY = useTransform(scrollY, [0, 300], [-20, 0]);4.2 针对容器与元素的滚动检测
useScroll默认监听浏览器窗口的滚动。但页面里经常存在独立滚动容器,比如一个固定高度、内部 overflow-y: auto 的弹窗面板。这时要给useScroll传container参数。
const containerRef = useRef(null); const { scrollYProgress } = useScroll({ container: containerRef });需要把ref同时挂在滚动容器上,motion 会改用这个容器的滚动位置来计算进度。
如果要监听某个元素在视口中的进出程度,可以用target和offset。offset: ["start end", "end start"]是我最常用的配置:元素顶部刚进入视口底部时进度为 0,元素底部离开视口顶部时进度为 1。
const targetRef = useRef(null); const { scrollYProgress } = useScroll({ target: targetRef, offset: ["start end", "end start"] });这个组合常用于做卡片进入视口时的视差背景,或者图片在滚动过程中的渐显渐隐。它的语义是:只要元素在视口范围内,scrollYProgress 就实时变化;元素完全在视口外时,进度保持 0 或 1。
4.3 whileInView 更省心,也有注意点
如果你只是希望元素"进入视口时播一次动画",那用whileInView会比手动组装 useScroll 和 useTransform 简单得多。
<motion.div initial={{ opacity: 0, y: 40 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true, amount: 0.3 }} > 滚动到我才出现的元素 </motion.div>viewport={{ once: true }}表示只触发一次,amount: 0.3表示元素露出 30% 时才触发。对于文章页面里的大段内容,我基本都使用once: true,避免用户反复上下滚动时元素不断重新播放动画,那是很干扰阅读的。
whileInView 的检测依赖浏览器 IntersectionObserver,这里有个实际限制:如果元素位于某个overflow-y: auto的容器内部,IntersectionObserver 的根是浏览器视口,不一定是那个容器,可能导致动画提前或延后触发。这个场景下我会放弃 whileInView,改用useScroll配合container参数自己量。
4.4 性能红线:哪些属性千万别用来做滚动动画
用 MotionValue 做滚动动画时,优先用 transform 相关的属性:y、x、scale、rotate,以及opacity。这些属性的动画由合成器处理,不会引发重新布局和绘制,主线程压力很小。
千万别把width、height、top、left、margin这类布局属性直接映射到滚动值上。它们每帧都要触发重排,页面很快就会掉帧。如果你的设计稿要求某个元素宽度随滚动变化,优先用scaleX模拟,或者想清楚是不是一定要实时驱动。这个原则在任何动画库里都成立,不只是 motion。
5. layout 与 layoutId:让元素自己"飞"到新位置
5.1 layout:位置和尺寸变化的自动补间
layout属性是 motion 的另一个实用功能:当元素的位置或尺寸发生变化时,motion 自动为这次变化补一段平滑动画。
<motion.div layout transition={{ type: "spring", stiffness: 300, damping: 30 }}> 内容 </motion.div>最常见的场景是 flex 布局里某个容器被点击后宽度从 1fr 变成 3fr,两侧的兄弟节点位置发生连锁变化。加了layout后,它们不是瞬间跳变,而是平滑移动到新位置。卡片列表的删除补位动画用的就是这个原理。
要注意的是,layout动画的本质是"测量变化前的布局位置和尺寸,再用 transform 把元素从旧位置补到新位置"。如果你同时在这个元素上手动编写了 transform 相关的whileHover或者 animate 动画,两者会相互覆盖,最终表现可能和你预期的完全不同。比如 hover 时明明写了放大 1.05,但 layout 动画也在利用 transform 做位移,放大的效果很可能就被覆盖了。
我的建议是:在同一个小范围内,不要同时依赖layout和whileHover的 transform。如果确实都要,可以把 hover 效果改成通过内部子元素实现,避免在一个元素上叠加两种 transform 控制逻辑。
5.2 layoutId:共享元素过渡的那点魔法
layoutId是让不同组件共享同一个动画身份的机制。当一个新组件挂载并且它声明的layoutId和之前已卸载的组件相同时,motion 会把新组件从旧组件的位置和尺寸"飞"过去。
最经典的用法是卡片点击展开弹窗:
<AnimatePresence> {selectedId && ( <motion.div layoutId={`card-${selectedId}`} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} > 弹窗内容 </motion.div> )} </AnimatePresence>这里弹窗和卡片用同一个layoutId。点击卡片后,弹窗不是凭空出现,而是从卡片的坐标和尺寸开始,放大到全屏弹窗的尺寸,视觉上像是卡片自己展开了。关闭时再缩回原位,整个交互非常连贯。
layoutId的另一个常见场景是 Tab 选中态背景。你在 Tab 栏底部放一个高亮块,给它layoutId="tab-highlight",切换到哪个 Tab 就把这个高亮块渲染到哪个 Tab 上,它会自己平移过去。
{activeTab === item && <motion.div layoutId="tab-highlight" className="tab-bg" />}这个效果我会尽量用来替代手写的 left/width 位移动画,因为layoutId不需要知道目标 Tab 的精确坐标,全部由测量完成,响应式布局下也不会失准。
5.3 几个一定要记住的坑
第一个坑是layoutId不能同时存在两个活跃实例。如果同一时间页面里有两个元素声明了相同的layoutId,motion 会不知道把哪个当"源头",动画会变得很随机。需要等一个卸载后再挂另一个,或者用AnimatePresence的时序控制好。
第二个坑是弹窗展开期间,弹窗内容可能被裁剪。因为过渡动画是从小尺寸放大到大尺寸,如果弹窗外层有overflow: hidden,放大过程中内容会被切掉。解决办法是过渡期间不要给弹窗套 overflow hidden,或者把内容用一个独立的 motion 子元素做延迟淡入,等尺寸稳定后再显示内容。
第三个坑是 z-index。共享元素过渡时,源元素和目标元素的层叠关系会影响视觉表现。展开弹窗时,弹窗本身要确保 z-index 高于源卡片,否则看起来像是卡片把弹窗盖住了。motion 官方推荐的做法是在AnimatePresence的模式里用custom或者把弹窗渲染到较高层级的容器中。
5.4 layout="position" 减少性能开销
如果元素变化主要是位置移动,尺寸没有变,可以在layout后面加"position":
<motion.div layout="position">这样 motion 只测量位置变化并补间位移,跳过尺寸测量,性能和稳定性都会更好。列表重新排序、Tag 换行这类场景,我几乎都直接写layout="position"。
6. 高频报错与排查速查
6.1 动画不触发或者一闪而过
最常见的原因是组件已经处于目标状态。比如animate="visible"写错成animate="show",或者变量名拼错,motion 会在控制台报警告但不会硬报错。另一个高频问题是父组件在每次渲染时都给animate传了一个新对象,导致组件被判定为"状态没有变化"或"每次都要变化",动画表现显得很飘忽。
我的排查顺序是:先看key是否稳定,再看initial和animate状态名是否都存在于对应 variants 对象里,最后看动画是否被某些全局样式(比如transition: none)覆盖。开发者工具里直接查 element 的 inline style,如果 motion 注入的 transform 为空,说明它根本没进入动画流程。
6.2 exit 失效或卡顿
exit 失效基本都是同一个原因:组件没有正确成为 AnimatePresence 的直接子级。AnimatePresence 只会追踪它包裹的直接子组件,如果你在它和 motion 组件之间插了一层自定义包装,或者用了一个非 motion 的 div,exit 就拿不到卸载信号。
另外一个常见原因是key没有变化。AnimatePresence 靠 key 区分元素,key 一旦没变化,它会认为元素还在原地,不会播放退场动画。列表操作里尤其容易踩,删除第 0 项时如果 key 还是 0,它检测不到变化。
卡顿的场景多数出在mode="sync"同时有大量离场和进场元素。改成mode="wait"或者在离场元素上减少同时动画的属性数量,能看到明显改善。
6.3 variants 的 stagger 失效
stagger 失效有两种典型情况。
第一种是子组件在 visible 状态里自定义了 transition,比如写了transition: { duration: 0.3 },这会打断父级传来的 stagger 节拍。解决方案是子组件不要写 transition,或者只写具体的 easing、duration,stagger 交给父级控制。
第二种是子组件没有声明 variants,却使用了initial="hidden" animate="visible"。这种情况下它不是 variants 体系里的成员,只会尝试在自身找对应状态,找不到自然没有动画。父级 variants 的 stagger 只对同样声明了 variants 的子级生效。
6.4 无障碍降级与性能基线
motion 提供了MotionConfig,可以统一处理用户系统的"减少动态效果"偏好:
import { MotionConfig } from "motion/react"; <MotionConfig reducedMotion="user"> {/* 整个应用 */} </MotionConfig>当用户系统开启"减少动态效果"时,reducedMotion="user"会让所有 transform 类动画自动降级为透明度变化,避免大位移、大缩放的动画给敏感用户带来不适。我在产品项目里基本都会套一层,成本低,收益明显。
性能方面我的底线是:所有动画尽量只动 transform 和 opacity;大量列表项不用 layout 动画;滚动驱动动画绝不碰宽度、高度、边距这类布局属性。做到这三点,motion 项目的帧率基本能稳在 60 帧附近。
还有一个开发阶段的小提醒:React StrictMode 在开发环境会双调用 effect,配合 AnimatePresence 时偶尔会出现动画"闪两下"的错觉。这个通常只在 dev 出现,生产构建不会受影响,不用太担心。
最后分享一个我自己的整体经验:我现在拿到一个 motion 需求,不会直接上手写代码,而是先想清楚这个动画属于进场、退场、手势、滚动还是布局过渡,然后决定用哪一组 API。进场的核心是 variants,退场找 AnimatePresence,交互手感用 while 系列,滚动联动找 useScroll 和 useTransform,位置变化补间用 layout。想清楚分类,方案基本就浮出来了。后面如果你有兴趣,我接着写第三篇,聊聊 motion 的内部机制和更进阶的自定义动画方案,比如 useMotionValueEvent、useSpring 和 useAnimate 的组合玩法。