去年年中的时候,我写过一篇motion动画库的基础入门,把motion组件、initial、animate、transition、whileHover这些单个元素上的用法过了一遍。那篇发出去之后,后台留言里问得最多的反而不是这些孤立 API,而是“怎么把动画组合起来用在真实页面上”:列表里十几个元素要按顺序依次进场怎么排?卡片拖到边界弹回来,怎么判断用户到底是想拖走还是只是手滑?页面往下滚的时候,导航栏的透明度要跟着变,总不能写一堆window.addEventListener('scroll')吧?还有列表删掉一项之后剩下的项“唰”地一下就自动补位了,这到底是哪来的特效?
这些问题其实都在motion动画库的基础范围之内,只是需要把几个 API 串起来看。这篇就挑 Variants、拖拽手势、滚动联动、布局动画四个方向最常见的套路展开,每个部分都给出可以直接跑的示例代码,最后再合体做一个真实面板。默认你已经能写单个motion组件的基础动画了,如果第一篇还没看,建议先补一下再往下读。
1. Variants:把零散动画声明收拢成一套状态机
1.1 为什么需要Variants
单元素动画时代,你通常是这样写的:
<motion.div initial={{ opacity: 0, y: 30 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5 }} />这样写很直观,但问题也很明显:同一套“从底部淡入”的动效,如果页面上有十几个元素,你就要复制十几份几乎一样的对象。更麻烦的是,元素之间还要有时差——第一个进、第二个隔 0.1 秒进、第三个再隔 0.1 秒进——这时候你不得不在每个元素的transition里手工算delay,比如第 3 个写delay: 0.2,第 8 个写delay: 0.7。哪天真在中间插了一项,后面所有delay全部要改一遍。这种体验写一次就够够的了。
Variants 就是用来解决这个问题的。它把一组动画状态集中声明在一个对象里,然后用字符串去引用。你可以把它理解为“给动画状态起名字”:叫hidden的状态长什么样、叫visible的状态长什么样,都事先登记好,组件需要时直接喊一声“我现在切到 visible”。所有分散的动画配置被收拢到一个地方,项目里如果想统一调整动效风格,只需要改这一个对象,而不是去页面里翻各种散落的initial和animate。
1.2 借助staggerChildren编排列表入场
用一个卡片列表做例子。父级用motion.ul,子级是若干motion.li,动画状态全部集中在两个 variants 对象里:
import { motion } from "motion/react"; const listVariants = { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { staggerChildren: 0.12, delayChildren: 0.2, }, }, }; const itemVariants = { hidden: { opacity: 0, y: 24 }, visible: { opacity: 1, y: 0, transition: { duration: 0.4 }, }, }; export default function CardList() { const items = [1, 2, 3, 4, 5]; return ( <motion.ul variants={listVariants} initial="hidden" animate="visible"> {items.map((n) => ( <motion.li key={n} variants={itemVariants}> <span>卡片 {n}</span> </motion.li> ))} </motion.ul> ); }关键机制是“变体传播”:父级motion.ul的状态切换成visible时,子级motion.li也会跟着切到同名的变体——前提是子级也定义了同名的对象。于是你只需要在父级的visible状态里写一个staggerChildren: 0.12,motion 就会自动把子级的入场时间错开 0.12 秒。列表想加一个元素?在 JSX 里加一行就好,延时的事情它自己算,完全不用改任何transition。
delayChildren是让父级整体先等 0.2 秒再开始播子元素,适合“父级先有轻微动作、子级再跟上”的场景。如果你的入场顺序希望反过来,先让子级动画播完,父级再收尾,可以在父级visible.transition里加when: "afterChildren"。这个when平时用得不多,但做“子元素先展开、外层容器最后改变尺寸”这类手风琴效果时非常关键,不写你会发现子级还在长,父级尺寸已经跳过去了。
这里有一个职责划分的细节:父级变体负责时间编排,子级变体负责自身效果。不要把staggerChildren写进每个子级自己的transition,那样父子会互相干扰,时间差会乱掉。把“什么时机”和“什么效果”分清楚,后续调试会省很多时间。
1.3 custom传递动态参数
staggerChildren 解决的是“错开时间”,但有时候你希望第 n 个元素不只是延迟入场,还要从不同方向进来,比如奇数从左、偶数从右。这时候要借助custom:
const itemVariants = { hidden: (dir) => ({ opacity: 0, x: dir * 40 }), visible: { opacity: 1, x: 0, transition: { duration: 0.35 } }, }; <motion.ul initial="hidden" animate="visible"> {items.map((item, i) => ( <motion.li key={item.id} custom={i % 2 === 0 ? -1 : 1} variants={itemVariants} /> ))} </motion.ul>变体后面跟的不再是静态对象,而是一个返回对象的函数,入参就是custom传进来的值。写业务的时候,最常见的用法其实不是方向,而是用custom传“某条数据的评分、等级、尺寸”,让动画强度随数据变化。比如列表越往上优先级越高,入场位移越大,这种事用custom做就非常干净。
我自己的习惯是:凡是需要从 JSX 数据里派生动画参数的,一律走custom,不要用闭包变量的方式临时拼对象。闭包写法在简单例子里看着省事,但只要组件更新频繁,很容易拼出“本次点击带上了上次的状态”这种脏数据。custom是官方提供的数据通道,语义明确,排错也容易。
实操中比较隐蔽的问题:一个列表既要staggerChildren又要custom时,custom要挂在子级上,不是父级。父级的custom会作为“公共参数”传给每个子级,子级自己定义的custom优先。如果你发现所有子级拿到的都是同一个值,检查一下是不是把custom写在了父级motion.ul上。
2. 拖拽与手势:让元素跟着“手”走
2.1 拖拽约束与回弹
drag属性把静态元素变成可拖拽元素。最简单的写法:
import { motion } from "motion/react"; import { useRef } from "react"; export default function DragDemo() { const boardRef = useRef(null); return ( <div ref={boardRef} className="board" style={{ width: 360, height: 240 }}> <motion.div className="card" drag="x" dragConstraints={boardRef} dragElastic={0.3} whileDrag={{ scale: 1.04, boxShadow: "0 10px 30px rgba(0,0,0,0.2)" }} > 左右拖拽我 </motion.div> </div> ); }drag="x"限制只能横向拖,dragConstraints={boardRef}把可拖范围限制在容器board内部,dragElastic控制“拖过头”时的弹性阻力。0 代表完全没有弹性、拖到头就停;0.3 表示允许超过边界 30%,松开后回弹。whileDrag在拖拽过程中应用样式,这里加了一点缩放和阴影,让卡片在手底下有“被提起来”的感觉。
这里最值得说的是info参数。onDragEnd回调里的第二个参数会带一份拖拽详情,其中info.offset.x是“这次拖动总共发生的位移”,info.delta.x是“最后一次事件相对上一次事件的增量”。判断“用户有没有把卡片拖出足够距离”用offset,写惯性、跟手特效用delta。两者别搞混,我第一次用的时候把delta当offset用,结果手松得快的卡片永远触发不了删除逻辑。
2.2 拖拽阈值判断与业务回调
大部分拖拽场景的判断逻辑很统一:要么到达阈值触发业务,要么没到阈值弹回原位。你不需要自己把位置算得很精确,直接在onDragEnd里判断:
import { useMotionValue } from "motion/react"; const x = useMotionValue(0); const handleDragEnd = (event, info) => { if (info.offset.x > 120) { onSwipe("right"); } else if (info.offset.x < -120) { onSwipe("left"); } else { // 没到阈值,让卡片依靠 dragElastic 自己弹回 } }; <motion.div style={{ x }} drag="x" dragConstraints={{ left: 0, right: 0 }} dragElastic={0.4} onDragEnd={handleDragEnd} />注意dragConstraints={{ left: 0, right: 0 }}配合dragElastic的效果:元素允许拖出去一段距离(由 elastic 决定),但只要松手,又会弹回原位。这跟“用 ref 限制在容器内”是两个交互语义,前者是“可甩动但不能带走”,适合邮件列表左滑操作、卡片右滑喜欢/不喜欢;后者是“限制在给定边界里”,适合画布类的平移拖拽。
dragElastic的大小直接决定手感。默认 0.5 其实不小了,卡片能拖出去接近半个身位,视觉上比较“飘”。做轻量删卡片交互,建议调到 0.2~0.4 之间,既明显又干脆。关闭手势之后不想让元素突然定住,可以用dragMomentum={false}关掉惯性。有些场景开完惯性,松手后卡片还会滑出去一大截,阈值判断就容易失控。
2.3 用dragControls做“把手”拖拽
还有一类常见交互:整块区域不是拖拽目标,只有点住某个“把手”才能拖动,典型的就是弹窗顶部标题栏。motion 为此提供了useDragControls:
import { useDragControls } from "motion/react"; export default function Modal() { const controls = useDragControls(); return ( <motion.div drag controls={controls} dragConstraints={{ left: 0, right: 0, top: 0, bottom: 0 }} > <div className="modal-header" onPointerDown={(e) => controls.start(e)}> 按住这里拖动弹窗 </div> <div className="modal-body">内容区随便点击,不会触发拖拽</div> </motion.div> ); }需要在被拖拽的motion.div上同时传drag和dragControls={controls},然后在想当把手的元素上通过onPointerDown调用controls.start(e)。controls.start接收原生指针事件,motion 会在内部接管后续的移动和释放。
之所以用onPointerDown而不是onMouseDown,是因为 Pointer 事件统一处理了鼠标、触控笔和触摸屏。一次实现,桌面和手机表现一致。做交互组件的时候,鼠标正常、触屏没反应是很常见的坑,从源头用 pointer 事件能少踩很多。
拖拽还有一个容易忽略的性能点:拖拽时元素会高频变换,如果内部再渲染了一大片子组件,会有明显掉帧。可以把不参与拖拽的内容拆出去,只让承载拖拽的层受 transform 影响,或者干脆用dragMomentum={false}关掉惯性。复杂面板的拖拽,我基本都是这样拆的。
3. 滚动联动与视口触发:MotionValue的数据流思维
3.1 为什么别用setState监听滚动
滚动这类高频事件,如果沿用普通思维“监听 scroll → 读 scrollTop → 算比例 → setState → 重渲染”,问题在于 React 每次setState都会触发整个组件树的协调。滚动位置一变、组件就全部重新渲染一次,页面滚动过程中掉帧几乎是必然的。
motion 给出的答案是MotionValue。你可以把它理解成一根“会变化的导线”:它自己存着当前值,并且允许后续的动画、样式直接订阅这个值的变化。你只监听一次滚动,把scrollYProgress存进 MotionValue,滚动发生时它自动更新,DOM 上的transform、opacity随之刷新,整个过程不经过 React 的 diff。因为响应滚动的是动画系统本身,而不是 React 渲染,开销小一个数量级。
这种思路和 React 的状态管理完全是两回事:MotionValue 是数据流,useState 是渲染状态。凡是“高频变化的临时数据”,优先扔给 MotionValue;只有那些“变化后必须重新计算 JSX 结构”的数据,才值得放进useState。
3.2 useScroll + useTransform:导航栏渐变案例
用导航栏滚动渐变举个例子:
import { motion, useScroll, useTransform } from "motion/react"; export default function ScrollNav() { const { scrollYProgress } = useScroll(); const navBg = useTransform( scrollYProgress, [0, 0.2], ["rgba(255,255,255,0)", "rgba(255,255,255,0.92)"] ); const navShadow = useTransform( scrollYProgress, [0, 0.2], ["none", "0 4px 20px rgba(0,0,0,0.12)"] ); return ( <motion.nav style={{ backgroundColor: navBg, boxShadow: navShadow, }} > {/* 导航内容 */} </motion.nav> ); }useScroll()不传参数也能用,默认监听页面滚动,返回的scrollYProgress是一个 0 到 1 的 MotionValue——页面在最顶端是 0,最底部是 1。useTransform给这根导线加一道转换管道:输入范围[0, 0.2]映射到输出["rgba(...,0)", "rgba(...,0.92)"]。滚动超过页面 20% 之前,导航背景会从透明逐渐变实白,阴影同步出现。
这种做法的核心优势是“声明式”:你只需要声明“滚动位置和样式之间的关系”,至于滚动事件怎么派发、哪个值先更新,都不需要管。跟“监听滚动 → 读 scrollTop → 算比例 → setState → 重渲染”相比,代码量约等于原来的零头,心智负担也小得多。
如果想要更“跟手”的进度效果,可以叠加useSpring做阻尼:
const { scrollYProgress } = useScroll(); const progress = useSpring(scrollYProgress, { stiffness: 120, damping: 30, restDelta: 0.001, }); <motion.div className="progress-bar" style={{ scaleX: progress, transformOrigin: "left" }} />顶部阅读进度条这种东西,三行代码就搞定了。注意transformOrigin: "left"一定要加,否则进度条默认从中心向两边展开,看起来就是两根反向的进度条。restDelta调小一点可以让进度条几乎完全停稳,避免“始终在细微抖动”的廉价感。
3.3 whileInView与viewport选项
滚动联动还有一个高频场景:元素进入视口才开始播放动画,也就是文档里常说的“滚动到那就亮到那”。直接用whileInView:
<motion.section initial={{ opacity: 0, y: 60 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true, amount: 0.3 }} transition={{ duration: 0.6 }} > 滚动到 30% 可见时才淡入 </motion.section>viewport的once: true表示动画只播放一次,播完就不再监听;amount是“元素进入视口多少比例时触发”,0.3 表示 30% 可视。还有一个容易忽略的margin,作用和 CSS 的margin一样,能扩充或收缩判断区域。比如页面底部有固定高度的 footer,想提前 100px 触发动画,就可以写viewport={{ once: true, amount: 0.2, margin: "0px 0px -100px 0px" }}。
注意:whileInView会占据一个“状态位”。如果一个元素既要“进视口播一次”,又要“点击后切换状态”,建议拆成两层 motion 组件各管一种状态,避免状态优先级打架。我踩过一次很真实的坑:入场动画还没播完就点了卡片,整个卡片消失了几帧。拆成外层管入场、内层管点击之后,问题就彻底消失了。
4. layout与layoutId:让布局变化自己“长”出来
4.1 layout属性:列表补位的自动动画
layout属性可以理解为“给布局变化打一针动画”:元素自身尺寸或位置变化时,motion 自动测量变化前后的几何差值,用transform补一个平滑过渡。你不用写任何 keyframe,它自己就能把“从 A 点移到 B 点”补出来。
最常见的落地场景就是列表增删。一个筛选列表,删掉一项后,后面所有项会瞬间跳上去;给每项加上layout属性,它们就会整齐地滑上去:
<motion.li layout transition={{ duration: 0.3 }}>就这么简单。motion 在每次 render 时会测量元素布局,所以它能看到位置从旧值跳到新值的过程。但要说明一点:layout只能动画“几何关系”,也就是位置和尺寸,不能动画列表里具体内容的插入和删除——后者必须交给AnimatePresence处理。
4.2 删除场景:AnimatePresence + layout的标准组合
删除场景比单纯补位复杂一点:被删的项先做退出动画,剩余的项再做补位动画。这是AnimatePresence+layout的标准组合:
import { AnimatePresence, motion } from "motion/react"; import { useState } from "react"; const tasks = [ { id: 1, text: "第一项" }, { id: 2, text: "第二项" }, { id: 3, text: "第三项" }, ]; export default function List() { const [list, setList] = useState(tasks); const remove = (id) => setList((prev) => prev.filter((item) => item.id !== id)); return ( <ul style={{ listStyle: "none" }}> <AnimatePresence initial={false}> {list.map((item) => ( <motion.li key={item.id} layout initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: "auto" }} exit={{ opacity: 0, height: 0 }} transition={{ duration: 0.3 }} style={{ overflow: "hidden" }} > <div className="item-content" onClick={() => remove(item.id)}> {item.text} </div> </motion.li> ))} </AnimatePresence> </ul> ); }AnimatePresence负责监测子级motion.li的挂载和卸载,exit定义“被移除瞬间”的动画,layout让剩余元素在新布局下补位。三者配合起来,删除的完整视觉过程就是:被删项高度坍缩、透明消失,同一时刻后续项平滑往上补,全程没有一次突兀的跳动。
这里必须强调一个用过必踩的坑:AnimatePresence 的 children 必须带稳定且唯一的 key。如果列表项用数组下标当 key,删除后所有后续项的 key 都变了,AnimatePresence 会认为整个列表都换了新元素,退出动画会全部乱套。凡是要做增删改的列表,key 一律用业务 id,这条规则在哪个动画库下都成立。
4.3 layoutId跨组件共享动画
layoutId比layout更进一步,它让两个“长相相似但位置不同”的元素共享同一个动画身份。你把同一个layoutId分别放在两个组件上,motion 会识别出“这是同一个东西的两种形态”,于是自动在两者之间做一次形变加位移的过渡。
经典场景是图片列表点开大图详情。列表缩略图和详情大图是两个不同的组件实例,缩略图使用motion.img并声明layoutId,详情大图也声明同一个layoutId,点击之后大图会从容积图的位置“飞”到中间放大,关掉后又飞回去:
{selected ? ( <motion.div layoutId={`cover-${selected.id}`} className="detail-card"> <img src={selected.src} alt="" /> </motion.div> ) : ( <div className="thumb-list"> {photos.map((p) => ( <motion.img layoutId={`cover-${p.id}`} key={p.id} src={p.src} onClick={() => setSelected(p)} className="thumb" /> ))} </div> )}layoutId很强大,但有一个硬性要求:同一时刻页面上不能出现两个带相同layoutId的元素。做列表和详情的互斥显示时,必须保证两者不同时渲染。用三元表达式渲染就是为了这个。
layout 相关还有一个容易翻车的点:元素本身有入场 transform 动画时,别再直接加layout,除非你能接受二者叠加后的“瞬移”感。motion 会自动把几个 transform 源合并,但视觉预期很难控制。我的做法是:layout只用在结构稳定、重点是位置变化的组件上;要同时做入场位移的元素,用父容器做 layout,子元素自己只做入场动画,职责分开,问题自然消失。
5. 四种能力合体:做一个会“呼吸”的任务卡片面板
5.1 完整代码示例
四个能力分开看都不复杂,但真实页面从来是组合题。下面这个任务面板把前文的内容全用上了:进场顺序交给 Variants + stagger,删除动效交给 AnimatePresence,补位交给 layout,卡片左右滑动由 drag 接管:
import { useState } from "react"; import { motion, AnimatePresence } from "motion/react"; const initialTasks = [ { id: 1, text: "梳理本周需求" }, { id: 2, text: "更新项目文档" }, { id: 3, text: "发会议邀请" }, ]; const panelVariants = { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { staggerChildren: 0.12, delayChildren: 0.15 }, }, }; const itemVariants = { hidden: { opacity: 0, x: 80 }, visible: { opacity: 1, x: 0, transition: { duration: 0.35 } }, }; export default function TaskPanel() { const [tasks, setTasks] = useState(initialTasks); const removeTask = (id) => { setTasks((current) => current.filter((task) => task.id !== id)); }; return ( <motion.ul variants={panelVariants} initial="hidden" whileInView="visible" viewport={{ once: true, amount: 0.2 }} style={{ listStyle: "none", padding: 0, maxWidth: 480 }} > <AnimatePresence> {tasks.map((task) => ( <motion.li key={task.id} layout variants={itemVariants} exit={{ opacity: 0, x: -120, height: 0, marginBottom: 0 }} drag="x" dragConstraints={{ left: 0, right: 0 }} dragElastic={0.4} onDragEnd={(event, info) => { if (Math.abs(info.offset.x) > 120) { removeTask(task.id); } }} style={{ overflow: "hidden", background: "#fff", border: "1px solid #ddd", borderRadius: 10, padding: 14, marginBottom: 10, }} > <div style={{ display: "flex", justifyContent: "space-between" }}> <span>{task.text}</span> <button onClick={() => removeTask(task.id)}>删除</button> </div> </motion.li> ))} </AnimatePresence> </motion.ul> ); }整个面板滚到可视区后,卡片逐条从右侧滑入;每张卡片可以左右拖动,超过 120 像素松手会被删除,删除瞬间它向左滑出、高度坍缩,剩余卡片自动补位;面板自身只在第一次进入视口时播放动画。
写这个 demo 时最需要注意的是:面板级staggerChildren和列表级layout是同时在工作的。staggerChildren管“入场时谁先谁后”,layout管“布局变化时怎么补位”,两者都作用于 transform,但 motion 内部会按阶段处理。前提是layout必须一直存在,别因为过滤把属性弄丢;exit里的x: -120只会在移除瞬间生效,不会污染入场和拖拽。
5.2 动画源冲突与性能注意事项
组合使用时会碰到一个真正的坑:一个元素可能同时被variants的入场位移、drag的拖拽位移、layout的布局补位修改 transform。motion 会做内部合并,但如果你在元素的 CSS 里自己写了transition,就相当于通知浏览器:普通样式变化也要做渐变——结果布局补位和浏览器原生 transition 同时作用,元素会出现明显的“抽搐”。
所以规则很简单:motion 管理的动画,不要在同一个元素的 CSS 样式表里写 transition。一旦动画表现异常,永远先排查这一条,能省掉大量定位时间。
性能上我建议遵守两条经验:
- 只对
opacity和transform做动画,不要对width、height、top、left这类属性做持续动画。列表删除场景里height: 0是必要的退出效果,但尽量限定在exit里,别在常态动画里用。 - 大量元素同时运动时,可以给容器加
willChange: "transform"帮浏览器提前分层。但willChange不能滥用,元素多且长时间静止时留着它反而耗内存,最好配合动画结束回调手动回收。
最后补一个用户偏好问题。网站或系统里如果有大面积的位移动画,建议判断一下useReducedMotion。motion 提供同名 hook,返回用户是否开启了“减少动态效果”的系统偏好。开启时,可以把位移动画改成纯透明度变化,或者直接关掉非必要动画:
import { useReducedMotion } from "motion/react"; const reduce = useReducedMotion(); const transition = reduce ? { duration: 0 } : { type: "spring", stiffness: 300, damping: 30 };这个细节在个人项目里可能没人管,但一到公开展示或者面向真实用户的场景,价值就出来了。追求流畅的前提是尊重用户,这也是我认为 motion 做得比很多自研动画方案成熟的地方。
motion动画库的高阶能力还有很多,但这四个方向的组合已经足够撑起大多数内容页面。我自己现在的使用习惯,是先把“状态机”的思维焊死:任何一组动画,先想它有几个状态、状态之间怎么切,再想 transition 怎么编排,而不是一上来就写两个互不相关的initial和animate对象。Variants 会让代码量明显下降,出问题的概率也会小很多。
最后再分享一个小技巧:把项目里常见动效统一抽成常量文件,比如fadeInUp、staggerList、shrinkOut,组件里直接引用。团队协作时,新同事看到的是一本“动画字典”,而不是一堆散落的魔法数字。成本极低,收益长期。下一篇如果继续写,我想把 AnimatePresence 的嵌套退出和路由级动效单独拆开讲,那里面的坑比入门篇要多得多。