React动画进阶:Variants、拖拽、滚动与布局动画实战 去年年中的时候我写过一篇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} initialhidden animatevisible {items.map((n) ( motion.li key{n} variants{itemVariants} span卡片 {n}/span /motion.li ))} /motion.ul ); }关键机制是“变体传播”父级motion.ul的状态切换成visible时子级motion.li也会跟着切到同名的变体——前提是子级也定义了同名的对象。于是你只需要在父级的visible状态里写一个staggerChildren: 0.12motion 就会自动把子级的入场时间错开 0.12 秒。列表想加一个元素在 JSX 里加一行就好延时的事情它自己算完全不用改任何transition。delayChildren是让父级整体先等 0.2 秒再开始播子元素适合“父级先有轻微动作、子级再跟上”的场景。如果你的入场顺序希望反过来先让子级动画播完父级再收尾可以在父级visible.transition里加when: afterChildren。这个when平时用得不多但做“子元素先展开、外层容器最后改变尺寸”这类手风琴效果时非常关键不写你会发现子级还在长父级尺寸已经跳过去了。这里有一个职责划分的细节父级变体负责时间编排子级变体负责自身效果。不要把staggerChildren写进每个子级自己的transition那样父子会互相干扰时间差会乱掉。把“什么时机”和“什么效果”分清楚后续调试会省很多时间。1.3 custom传递动态参数staggerChildren 解决的是“错开时间”但有时候你希望第 n 个元素不只是延迟入场还要从不同方向进来比如奇数从左、偶数从右。这时候要借助customconst itemVariants { hidden: (dir) ({ opacity: 0, x: dir * 40 }), visible: { opacity: 1, x: 0, transition: { duration: 0.35 } }, }; motion.ul initialhidden animatevisible {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} classNameboard style{{ width: 360, height: 240 }} motion.div classNamecard dragx dragConstraints{boardRef} dragElastic{0.3} whileDrag{{ scale: 1.04, boxShadow: 0 10px 30px rgba(0,0,0,0.2) }} 左右拖拽我 /motion.div /div ); }dragx限制只能横向拖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 }} dragx 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 为此提供了useDragControlsimport { 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 classNamemodal-header onPointerDown{(e) controls.start(e)} 按住这里拖动弹窗 /div div classNamemodal-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 classNameprogress-bar style{{ scaleX: progress, transformOrigin: left }} /顶部阅读进度条这种东西三行代码就搞定了。注意transformOrigin: left一定要加否则进度条默认从中心向两边展开看起来就是两根反向的进度条。restDelta调小一点可以让进度条几乎完全停稳避免“始终在细微抖动”的廉价感。3.3 whileInView与viewport选项滚动联动还有一个高频场景元素进入视口才开始播放动画也就是文档里常说的“滚动到那就亮到那”。直接用whileInViewmotion.section initial{{ opacity: 0, y: 60 }} whileInView{{ opacity: 1, y: 0 }} viewport{{ once: true, amount: 0.3 }} transition{{ duration: 0.6 }} 滚动到 30% 可见时才淡入 /motion.sectionviewport的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的标准组合删除场景比单纯补位复杂一点被删的项先做退出动画剩余的项再做补位动画。这是AnimatePresencelayout的标准组合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 classNameitem-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}} classNamedetail-card img src{selected.src} alt / /motion.div ) : ( div classNamethumb-list {photos.map((p) ( motion.img layoutId{cover-${p.id}} key{p.id} src{p.src} onClick{() setSelected(p)} classNamethumb / ))} /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} initialhidden whileInViewvisible 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 }} dragx 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 的嵌套退出和路由级动效单独拆开讲那里面的坑比入门篇要多得多。