资讯详情 motion进阶实战:variants编排、离场动画与滚动驱动的完整指南
📅 2026/10/20 18:52:31
先把话说清楚这篇文章是 motion 基础用法的第二篇。上一篇里你大概率已经写过类似motion.div animate{{ x: 100 }}的效果也知道了 initial、animate、transition 这几个核心属性分别管什么。这篇我要把范围稍微拉大一点聊的是那些让动画之间产生关系的 APIvariants 的编排、离场动画、手势交互、滚动驱动、布局过渡。用到的库依然是 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} initialhidden animatevisible {/* 卡片内容 */} /motion.div ); }注意这里initialhidden和animatevisible传的是状态名不是具体的值。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}并且initialhidden animatevisible它们就会按照父级的编排批量进场。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} initialhidden animatevisible /这个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} initialhidden animatevisible 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 keypanel 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属性控制进场元素和退场元素在时间上的重叠方式。modesync默认值进场和退场同时进行。适合大多数场景但两个元素同时在场的瞬间可能造成布局跳动。modewait先等退场动画完全结束再让进场元素入场。适合 Tab 切换、图片轮播这类需要严格时序的场景缺点是如果退场动画太长交互响应会显得慢。modepopLayout离场元素被设为绝对定位脱离文档流其余内容不受影响。这套机制非常适合列表删除的场合因为它在离场动画期间不会挤压剩余元素的位置。我的经验是Tab 切换内容比较独立时优先用wait视觉上干净利落不重叠但要注意把退场时长控制在 200ms 左右否则点击 Tab 会有明显的迟滞感。列表删除则直接用popLayout配合 layout 动画能让剩余项平滑补位。AnimatePresence modewait {activeTab home ? ( motion.div keyhome initial{{ opacity: 0 }} animate{{ opacity: 1 }} exit{{ opacity: 0 }} 首页内容 /motion.div ) : ( motion.div keyabout initial{{ opacity: 0 }} animate{{ opacity: 1 }} exit{{ opacity: 0 }} 关于内容 /motion.div )} /AnimatePresence2.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或者用layoutposition跳过尺寸测量只做位移补间。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 状态组件只写whileHoverhover whileTaptap状态逻辑全集中在 variants 里。这个写法在组件样式分支多的时候特别清楚。在卡片场景里我给卡片配置了whileHover{{ y: -4 }}和whileTap{{ scale: 0.98 }}。悬停时卡片微微上浮点击时轻微内缩配合盒子阴影用户马上能感觉到卡片可交互。这种手感的建立主要靠位移和阴影不是靠颜色变化。3.2 拖拽drag、dragConstraints、dragElastic拖拽属性是 motion 的拿手好戏drag设置可拖拽的方向dragConstraints设置边界dragElastic控制越界后的弹性dragSnapToOrigin让元素松手后回到原位。motion.div dragx dragConstraints{{ left: 0, right: 320 }} dragElastic{0.2} dragSnapToOrigin 拖我 /motion.divdragConstraints还可以传一个 ref 对象让元素被限制在某个容器范围内const containerRef useRef(null); motion.div ref{containerRef} motion.div drag dragConstraints{containerRef} / /motion.divdragConstraints 接收 ref 时motion 会在拖拽开始前计算出容器的实际边界元素只能在容器内部移动。这比手写固定像素值要灵活得多配合响应式布局也不会跑偏。实际开发里我经常用这个 API 做移动端轮播、抽屉面板、下拉刷新这类交互。在卡片列表场景我让卡片支持横向拖动一定距离超过阈值松手后会先弹出一段删除确认再回弹本质上就是dragSnapToOrigin加onDragEnd判断偏移量。motion.div dragx 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 dragx dragControls{controls} motion.div onPointerDown{(e) controls.start(e)} classNamehandle 按住这里拖动 /motion.div button正常点击按钮/button /motion.div ); }这里的关键是把onPointerDown绑在拖拽手柄上而不是整个元素上。这样卡片内部的按钮、链接不会和拖拽冲突用户只能通过手柄区触发拖动。controls.start接收原生 PointerEventmotion 内部会接着处理后续的 pointermove 和 pointerup。4. 滚动驱动动画让页面跟随滚轮平滑变化4.1 useScroll 与 useTransform 的基础闭环滚动动画的核心是两个 APIuseScroll生成 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 到 10 表示页面在顶部1 表示滚动到底部。useTransform做的就是输入区间到输出区间的映射输入到达 0.5 时opacity 已经线性降到 0scale 则到 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.divviewport{{ 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 栏底部放一个高亮块给它layoutIdtab-highlight切换到哪个 Tab 就把这个高亮块渲染到哪个 Tab 上它会自己平移过去。{activeTab item motion.div layoutIdtab-highlight classNametab-bg /}这个效果我会尽量用来替代手写的 left/width 位移动画因为layoutId不需要知道目标 Tab 的精确坐标全部由测量完成响应式布局下也不会失准。5.3 几个一定要记住的坑第一个坑是layoutId不能同时存在两个活跃实例。如果同一时间页面里有两个元素声明了相同的layoutIdmotion 会不知道把哪个当源头动画会变得很随机。需要等一个卸载后再挂另一个或者用AnimatePresence的时序控制好。第二个坑是弹窗展开期间弹窗内容可能被裁剪。因为过渡动画是从小尺寸放大到大尺寸如果弹窗外层有overflow: hidden放大过程中内容会被切掉。解决办法是过渡期间不要给弹窗套 overflow hidden或者把内容用一个独立的 motion 子元素做延迟淡入等尺寸稳定后再显示内容。第三个坑是 z-index。共享元素过渡时源元素和目标元素的层叠关系会影响视觉表现。展开弹窗时弹窗本身要确保 z-index 高于源卡片否则看起来像是卡片把弹窗盖住了。motion 官方推荐的做法是在AnimatePresence的模式里用custom或者把弹窗渲染到较高层级的容器中。5.4 layoutposition 减少性能开销如果元素变化主要是位置移动尺寸没有变可以在layout后面加positionmotion.div layoutposition这样 motion 只测量位置变化并补间位移跳过尺寸测量性能和稳定性都会更好。列表重新排序、Tag 换行这类场景我几乎都直接写layoutposition。6. 高频报错与排查速查6.1 动画不触发或者一闪而过最常见的原因是组件已经处于目标状态。比如animatevisible写错成animateshow或者变量名拼错motion 会在控制台报警告但不会硬报错。另一个高频问题是父组件在每次渲染时都给animate传了一个新对象导致组件被判定为状态没有变化或每次都要变化动画表现显得很飘忽。我的排查顺序是先看key是否稳定再看initial和animate状态名是否都存在于对应 variants 对象里最后看动画是否被某些全局样式比如transition: none覆盖。开发者工具里直接查 element 的 inline style如果 motion 注入的 transform 为空说明它根本没进入动画流程。6.2 exit 失效或卡顿exit 失效基本都是同一个原因组件没有正确成为 AnimatePresence 的直接子级。AnimatePresence 只会追踪它包裹的直接子组件如果你在它和 motion 组件之间插了一层自定义包装或者用了一个非 motion 的 divexit 就拿不到卸载信号。另外一个常见原因是key没有变化。AnimatePresence 靠 key 区分元素key 一旦没变化它会认为元素还在原地不会播放退场动画。列表操作里尤其容易踩删除第 0 项时如果 key 还是 0它检测不到变化。卡顿的场景多数出在modesync同时有大量离场和进场元素。改成modewait或者在离场元素上减少同时动画的属性数量能看到明显改善。6.3 variants 的 stagger 失效stagger 失效有两种典型情况。第一种是子组件在 visible 状态里自定义了 transition比如写了transition: { duration: 0.3 }这会打断父级传来的 stagger 节拍。解决方案是子组件不要写 transition或者只写具体的 easing、durationstagger 交给父级控制。第二种是子组件没有声明 variants却使用了initialhidden animatevisible。这种情况下它不是 variants 体系里的成员只会尝试在自身找对应状态找不到自然没有动画。父级 variants 的 stagger 只对同样声明了 variants 的子级生效。6.4 无障碍降级与性能基线motion 提供了MotionConfig可以统一处理用户系统的减少动态效果偏好import { MotionConfig } from motion/react; MotionConfig reducedMotionuser {/* 整个应用 */} /MotionConfig当用户系统开启减少动态效果时reducedMotionuser会让所有 transform 类动画自动降级为透明度变化避免大位移、大缩放的动画给敏感用户带来不适。我在产品项目里基本都会套一层成本低收益明显。性能方面我的底线是所有动画尽量只动 transform 和 opacity大量列表项不用 layout 动画滚动驱动动画绝不碰宽度、高度、边距这类布局属性。做到这三点motion 项目的帧率基本能稳在 60 帧附近。还有一个开发阶段的小提醒React StrictMode 在开发环境会双调用 effect配合 AnimatePresence 时偶尔会出现动画闪两下的错觉。这个通常只在 dev 出现生产构建不会受影响不用太担心。最后分享一个我自己的整体经验我现在拿到一个 motion 需求不会直接上手写代码而是先想清楚这个动画属于进场、退场、手势、滚动还是布局过渡然后决定用哪一组 API。进场的核心是 variants退场找 AnimatePresence交互手感用 while 系列滚动联动找 useScroll 和 useTransform位置变化补间用 layout。想清楚分类方案基本就浮出来了。后面如果你有兴趣我接着写第三篇聊聊 motion 的内部机制和更进阶的自定义动画方案比如 useMotionValueEvent、useSpring 和 useAnimate 的组合玩法。