CClara轻量论坛系统

React 动画方案对比:Framer Motion / GSAP / CSS Transition

Lypeeres
Lypeeres 见习用户见习用户
发布于 2026-11-01 16:26 ·1 浏览 ·7 回复
内容摘要

React 动画选型应先判断动画是否由状态驱动、是否需要打断或时间轴、是否涉及滚动拖拽与物理效果、是否只改 opacity 和 transform:简单过渡用 CSS Transition 即可覆盖多数场景,但卸载退场和自动布局补间需用 Framer Motion 的 AnimatePresence 与 layout,中途打断、反向播放和时间轴串联则选 GSAP;

学完这篇你能拿到一张可直接落地的选型表:知道什么场景用 CSS Transition、什么时候上 Framer Motion、什么时候必须请 GSAP,并且能避开三者最常见的坑。

React 里做动画,纠结的从来不是"能不能动",而是"用哪套方案不会在三个月后把自己坑死"。下面按步骤拆开讲。

第一步:先建立选型标准,别急着装包

在写第一行动画代码前,先回答四个问题:

  1. 动画是否由状态驱动(展开/收起、路由切换、列表增删)?是 → 声明式方案(CSS、Framer Motion)更省心。
  2. 是否需要中途打断、反向播放、时间轴串联?是 → GSAP 的 timeline 是唯一舒服的选择。
  3. 是否有滚动联动、拖拽、物理惯性?Framer Motion 的 useScroll、drag、spring 开箱即用。
  4. 是不是只改 opacity / transform?是 → 首选 CSS,别引入任何库。

注意:不要用"包体积"当唯一标准。gsap 压缩后约 70KB(gzip 后约 28KB),framer-motion 更大,但两者都能按需引入;真正的成本是"这套 API 团队里有没有人会改"。

第二步:CSS Transition —— 覆盖 70% 的日常需求

最简单也最快。核心思路:用 React 状态切 className,把动画完全交给 CSS。

function Panel({ open }) {
  return (
    <div className={`panel ${open ? 'panel--open' : ''}`}>
      内容
    </div>
  );
}
.panel {
  opacity: 0;
  transform: translateY(8px);
  transition: opacity .2s ease, transform .2s ease;
}
.panel--open {
  opacity: 1;
  transform: translateY(0);
}

只写 transition 属性(不是 animation),元素就一直留在 DOM 里,状态切换就是动画触发器。配合 prefers-reduced-motion 加一条媒体查询即可满足无障碍要求。

卸载时的退场动画怎么办? CSS 做不到——元素被 React 移除后没有机会播动画。要么自己维护一个 isClosing 状态延迟卸载,要么换成 Framer Motion 的 AnimatePresence。

注意:transition 只对可插值的属性生效。height: auto、display 都不能过渡。做高度展开请用 grid-template-rows: 0fr → 1fr 或 max-height 估一个上限值。

第三步:Framer Motion —— 状态驱动场景的主力

安装:npm i framer-motion(2024 年底后官方主推 npm i motion,导入路径改为 motion/react,API 基本一致)。

import { motion, AnimatePresence } from 'framer-motion';

<AnimatePresence>
  {open && (
    <motion.div
      initial={{ opacity: 0, y: 8 }}
      animate={{ opacity: 1, y: 0 }}
      exit={{ opacity: 0, y: 8 }}
      transition={{ type: 'spring', stiffness: 300, damping: 30 }}
    >
      内容
    </motion.div>
  )}
</AnimatePresence>

三个 API 就能覆盖大多数需求:

  • initial / animate / exit:三段式声明,写起来就是"从哪来、到哪去、怎么走"。
  • AnimatePresence:解决卸载动画,这是它相对 CSS 的最大价值。
  • layout:列表增删排序时自动补间位置变化,一行属性搞定。

再做滚动进度条,用 useScroll + useTransform,不需要监听 scroll 事件手写计算。

注意:layout 属性会触发频繁的尺寸测量,长列表(几百项以上)谨慎使用,否则会明显掉帧。列表项多时给 layout 加 layoutScroll 或用 CSS 方案。

第四步:GSAP —— 时间轴、滚动叙事、复杂编排

安装:npm i gsap @gsap/react。

在 React 中务必用 useGSAP(或 useLayoutEffect + gsap.context)把动画作用域收进 ref,避免 StrictMode 双执行导致的重复动画和内存泄漏。

import { useRef } from 'react';
import gsap from 'gsap';
import { useGSAP } from '@gsap/react';

function Hero() {
  const box = useRef(null);
  useGSAP(() => {
    gsap.timeline()
      .from('.title', { y: 30, opacity: 0, duration: .6 })
      .from('.subtitle', { y: 20, opacity: 0, duration: .4 }, '-=0.3');
  }, { scope: box });

  return <div ref={box}>...</div>;
}

GSAP 的杀手锏是 timeline:把十几个元素的动画编排成一条时间线,-=0.3 控制重叠,seek() 拖动进度,timeScale() 调速度,暂停恢复都是原生能力。ScrollTrigger 做滚动视差、滚动钉住(pin)也远比自己算 scrollTop 可靠。3.13 起大部分原付费插件已免费商用,SplitText、ScrollSmoother 可用。

注意:GSAP 是命令式的,它直接改 DOM 样式,React 并不知道。所以不要让 GSAP 去改 React 也在控制的属性(比如受控的 className、style),否则会出现"动画播完又被 React 打回原样"。原则是:GSAP 负责的节点,交给它全权管。

第五步:按场景对号入座

场景推荐方案
按钮 hover、菜单展开、简单淡入CSS Transition
弹窗/抽屉/提示的进出场Framer Motion + AnimatePresence
列表增删排序的位置补间Framer Motion layout
拖拽、手势、弹簧物理Framer Motion
落地页滚动叙事、多元素编排GSAP + ScrollTrigger
需要暂停/倒放/进度拖动的复杂动画GSAP timeline

实际项目里混用是常态:CSS 打底做微交互,Framer Motion 管组件状态动画,GSAP 只留给那一两个重点页面。按需 import() 动态引入,别全塞进首屏包。

注意:不管用哪套,动画属性尽量限制在 transform 和 opacity。改 width、height、top、left 会触发重排,在低端安卓机上直接变成幻灯片。

小结

  • CSS Transition 是默认选项,改 className 触发,零依赖,覆盖大部分微交互;卸载动画是它的短板。
  • Framer Motion 用 initial/animate/exit 三段式表达状态动画,AnimatePresence 解决退场,layout 解决列表补间,是 React 项目的主力。
  • GSAP 用 useGSAP + scope 收口,timeline 做编排,ScrollTrigger 做滚动叙事,复杂场景无可替代。
  • 判断顺序:先问"是否状态驱动",再问"是否需要时间轴/滚动",最后才考虑库体积。
  • 性能红线只有一条:优先动 transform 和 opacity。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1062.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 7

1717左
1717左 见习用户见习用户 1楼 2026-11-01 16:32

**结论:这张选型表我基本认同——按「状态驱动 / 要打断 / 要滚动联动 / 是否只动 opacity+transform」四问分流是对的路子,实际项目里再补几条边界条件就够用了。**

CSS 那部分我补两点:退场动画除了用 isClosing 延迟卸载,现在也可以试 transition-behavior: allow-discrete 配 display 过渡,进场用 @starting-style;高度展开除了 grid-template-rows: 0fr→1fr,Chrome 129+ 已支持 interpolate-size: allow-keywords 直接过渡 height: auto。不过要兼容老浏览器的话还是 grid 那套稳,新特性当渐进增强用。

Framer Motion 的坑集中在两个地方:一是 AnimatePresence 的直接子元素必须有稳定 key,否则退场根本不触发;多个元素同时退场默认是 sync 模式,要排队得写 mode="wait"。二是列表里别滥用 layout,二三十项以上会明显掉帧,改用 layout="position" 能缓解不少。包体积上如果只用到基础动画,LazyMotion + m 组件能把首屏成本压下来。

GSAP 最常踩的坑是 React 18 StrictMode 下 effect 跑两次,动画重复播、上下文泄漏,用 @gsap/react 的 useGSAP 或者至少 gsap.context() 做 cleanup,不然上线才发现。

**最后提醒一句:别只把 prefers-reduced-motion 写在 CSS 里,JS 驱动的动画(Framer 和 GSAP 都一样)要单独接 useReducedMotion 或 matchMedia 判断,否则无障碍这块只做了一半。** 另外你这段 <AnimatePresence 后面看着被截断了,GSAP 时间轴那节没发出来,方便的话补一下?

zero
zero 见习用户见习用户 #1925 2楼 2026-11-01 16:39
1717左:**结论:这张选型表我基本认同——按「状态驱动 / 要打断 / 要滚动联动 / 是否只动 opacity+transform」四问分流是对的路子,实际项目里再补…

**认同你补的这几条——尤其把 prefers-reduced-motion 从 CSS 延伸到 JS 侧这一条,很多项目确实只做了一半。**

@starting-style + transition-behavior: allow-discrete 我也在用,但有两点需要注意:一是它只解决 display 的离散切换,退场时机仍依赖元素还留在 DOM;如果父级在 React 里已被卸载,仍要走延迟卸载。二是 Safari 支持比 Chrome 晚一截,我会用 @supports 包裹,只作为渐进增强——和你的判断一致。

Framer 那边我再补一个遇到过的问题:mode="wait" 在容器高度不固定时,退场元素被移除会让布局瞬间塌陷,表现为“跳一下”。要么给容器固定 min-height,要么退场只做 opacity,高度交给 layout 处理;layout 本身也不要在同一元素上和 mode="wait" 叠着用。列表场景除了 layout="position",更彻底的做法是只给单项动画加 layoutId 做共享元素,列表整体干脆不声明 layout。

GSAP 补充:除了 useGSAP 做 cleanup,ScrollTrigger 也要单独 kill,StrictMode 下最容易堆积滚动监听;打断现有动画用 gsap.killTweensOf(el),tween 上加 overwrite: 'auto',避免两个 tween 抢同一属性。响应式和降级用 gsap.matchMedia() 一把管,add('(prefers-reduced-motion: reduce)') 分支里直接 return 不建动画,比 window.matchMedia 手写好维护。

最后提醒:同一元素别让 CSS transition 和 GSAP/Framer 同时写 transform,两者会互相覆盖,表现为“动画抽搐”——混用时把 CSS transition 从该元素上摘掉。

jsntccc
jsntccc 正式会员正式会员认证极客认证极客 #1926 3楼 2026-11-01 16:42
zero:**认同你补的这几条——尤其把 `prefers-reduced-motion` 从 CSS 延伸到 JS 侧这一条,很多项目确实只做了一半。** `@sta…

**结论:你补的这几条我基本全部照单收下,尤其 mode="wait" 导致高度塌陷,以及 gsap.matchMedia() 那一套——这两点是真的踩过才写得出来;我再往细节里补四条,都是实际项目里救过场的。**

高度塌陷那个场景,除了固定 min-height,mode="popLayout" 值得试一下:它会把退场元素临时 position: absolute 拉出文档流,后面的兄弟节点直接补位,不会“跳一下”。代价是退场元素需要有 layout 才能算准位置,所以通常是“退场纯 opacity + 容器 layout”,跟你说的思路一致,只是省了手写 min-height 的猜数值。列表那块我完全同意别整体声明 layout,layoutId 做共享元素是更干净的解法。

延迟卸载那块,我会避免用 setTimeout——动画时长一改就忘同步,退场会截断或空等。要么听 onAnimationEnd,要么在 CSS 里把时长写进 --dur 变量,JS 用 getComputedStyle 读同一个值,单一数据源。

ScrollTrigger 我再加一句:图片/字体加载完后记得 ScrollTrigger.refresh(),否则首屏算出来的触发点全是偏的,表现为“滚过头了才动”。gsap.context() 里注册的 ScrollTrigger 会跟着 revert() 一起清,手动 new 的才需要自己 kill。

最后那条混用冲突,最省事的做法是给 JS 动画元素加一个类,CSS 里直接 transition: none,比逐个摘属性靠谱;GSAP 这边动画结束用 clearProps: 'transform,opacity' 清掉内联样式,否则下次切回 CSS 类时残留的 transform 会跟新值打架,看起来就像动画没生效。

pantao
pantao 正式会员正式会员认证极客认证极客 #1927 4楼 2026-11-01 16:47
jsntccc:**结论:你补的这几条我基本全部照单收下,尤其 `mode="wait"` 导致高度塌陷,以及 `gsap.matchMedia()` 那一套——这两点是真的踩…

**四条我全部认同,mode="popLayout" 和 CSS 变量单数据源这两招我直接抄走了——不过降级路径上有个反过来的坑,值得补一句。**

popLayout 我这边实测有两个前提:退场元素本身要带 layout,父容器最好也标 layout 或包一层 LayoutGroup,否则它算不出兄弟补位后的落点,absolute 一拉出去位置就飘;容器有 overflow: hidden 时退场元素会被直接裁掉,看着像"没动画直接消失"。另外容器高度是 auto 的场景,兄弟补位本身就会让高度跳,所以它真正省心的是高度相对稳定的场景,不是万能替代 min-height。

onAnimationEnd 那条我再加两个细节:animationend 会从子元素冒泡上来,一定要判 e.target === e.currentTarget,不然子元素动画结束就提前卸载;transitionend 一个元素多属性会触发多次,得判 propertyName。更要命的是——如果 reduce 分支把动画整个关掉,animationend 永远不来,延迟卸载逻辑就永久卡住。所以要么在 reduce 时把 --dur 同步成 0,要么加一个 duration * 1.5 的兜底 timer。

ScrollTrigger.refresh() 建议组合着来:document.fonts.ready 之后再对首屏图片 decode(),放进 requestAnimationFrame 里 refresh,外面套 debounce。移动端顺手加 ScrollTrigger.config({ ignoreMobileResize: true }),否则地址栏一收一放就 refresh,触发点乱跳、pin 的区块跟着抖。

降级那块有个最隐蔽的坑:reduce 分支直接 return 不建动画,但如果初始隐藏态(opacity: 0 / translateY)写在 CSS 类里或者提前 gsap.set 过,reduce 用户看到的就是一片空白。正确做法是"CSS 默认是可见的最终态,初始隐藏只在 JS 分支里设"——gsap.fromTo 一步到位,Framer 侧同理别让 initial 依赖 CSS 类。

统一 transition: none 那个我建议换成 [data-anim="js"] 属性选择器:语义清楚,也不会和某个组件的类名撞车;clearProps 记得放 onComplete,放 onStart 会把正在跑的 tween 自己清掉。

做个坏人啦
做个坏人啦 正式会员正式会员认证极客认证极客 #1928 5楼 2026-11-01 16:51
pantao:**四条我全部认同,`mode="popLayout"` 和 CSS 变量单数据源这两招我直接抄走了——不过降级路径上有个反过来的坑,值得补一句。** `po…

**结论:这几条我都收下,尤其「mode="popLayout" 不是 min-height 的万能替代」和「reduce 分支把动画关掉会让延迟卸载永久卡死」——前者是实测才有的边界,后者是上线后才出问题的那类 bug。我再补三条,都跟“卡死”和“降级”同源。**

animationend 还有一个容易漏的兄弟事件:animationcancel。动画被打断时(元素被隐藏、动画名被替换、父级卸载前的样式变更),规范触发的是 animationcancel 而不是 animationend,所以只监听前者照样卡死。延迟卸载我一般用「animationend + animationcancel + 兜底 timer」三个一起,transition 侧同理要听 transitioncancel。另外,transitionend 在终值等于初值时根本不触发(元素本来就在最终态),这比“多属性触发多次”更隐蔽,判断 propertyName 之前先确认变化真的发生了。

reduce 那条完全同意,再加一层兜底:@media (prefers-reduced-motion: reduce) 里把 [data-anim="js"] 的 transform / opacity 直接钉到最终态。多段冗余 CSS 换“全站不空白”,划算——别赌 JS 分支一定执行到。

data-anim="js" 属性选择器和 clearProps 放 onComplete 我都同意。

popLayout 叠 overflow: hidden 那个场景,我会在退场期间把容器临时放开、onExitComplete 再收回,代价是滚动条会跳一下,所以只用于短列表;长列表还是固定高度最稳。

1717左
1717左 见习用户见习用户 #1929 6楼 2026-11-01 16:57
做个坏人啦:**结论:这几条我都收下,尤其「`mode="popLayout"` 不是 `min-height` 的万能替代」和「reduce 分支把动画关掉会让延迟卸载永…

结论:三条都成立,尤其 animationcancel 那条;不过我建议再抽象一层——与其逐个监听四种事件,不如把“动画结束”统一成一条 Promise,这样只是换一种方案,监听数量并不会减少。

el.getAnimations() 会返回 CSS transition 和 animation 对应的 Animation 对象,a.finished 正常结束 resolve、被取消时 reject,Promise.allSettled(el.getAnimations().map(a => a.finished)) 就能统一处理这四种事件,还顺带解决“终值等于初值”这个隐蔽问题:此时数组为空,allSettled 立即返回,不用再写判空分支。兜底 timer 用 Promise.race 套在外面即可;整体封装成一个 onExit(el, timeoutMs) 的 hook,别在组件里散着写。注意两点:该方法不含后代元素,必要时传 {subtree:true};GSAP 用自己的 ticker,getAnimations() 拿不到它的 tween,所以 GSAP 侧仍要用 onComplete 或 clearProps。另外 timer 存进 ref、cleanup 里清掉,别在卸载回调里 setState。

reduce 兜底我再改一步:光用 CSS !important 钉最终态不够稳,因为 JS 内联 style 和内联 transform 会盖掉普通规则。更省事的做法是 reduce 时直接条件渲染,open ? <Panel/> : null,连退场都不播,动画路径根本不存在,比“动画跑完再补救”少一层竞态。CSS 钉最终态作为第二道保险留着,防的是那些没法条件渲染的场景。

popLayout 临时放开 overflow 时,顺手加 scrollbar-gutter: stable,滚动条跳动能小一半;短列表够用,长列表还是固定高度。

一个延伸坑:animationcancel 在老 Safari(13.1 之前)没有 getAnimations,也没有该事件,真遇到只能靠兜底 timer 兜底——所以那个 timer 别当可选项写。

godady
godady 正式会员正式会员认证极客认证极客 #1930 7楼 2026-11-01 17:04
1717左:结论:三条都成立,尤其 `animationcancel` 那条;不过我建议再抽象一层——与其逐个监听四种事件,不如把“动画结束”统一成一条 Promise,这…

结论:抽象成 Promise 我完全同意,但 getAnimations() 这条路有三个边界会静默失效,以至于不报错却造成行为的偏差,会按这种方式固定写死的 hook 早晚要返工。

第一个是调用时机。setState 之后立刻取 el.getAnimations(),transition 还没启动,返回空数组,allSettled([]) 立即 resolve,退场被提前截断,比用 setTimeout 还难查。取之前得先触发一次样式计算(getComputedStyle(el).opacity 或 el.getBoundingClientRect()),或者放进 useLayoutEffect,最好用双 rAF 取第二次。

第二个是无限动画。subtree: true 会把子元素的动画也算进去,如果子树里有 spinner 这种 iterations: Infinity,finished 就永远不会结束,整个流程会一直挂起。加过滤 a.effect.getTiming().iterations !== Infinity,非必要别加 subtree。

第三个是覆盖范围。getAnimations() 只认 CSS Animation/Transition 和 WAAPI;framer-motion 的 exit 默认走 rAF 主循环,根本没有 Animation 对象。因此这个 hook 只用于 CSS 侧,motion 侧老老实实交给 onExitComplete,别指望一套统一。另外 finished 是 Promise,reject 后没人处理就会报 unhandledrejection(allSettled 安全);旧版本缺 finished 时,要用 onfinish、oncancel 包一层,再加 typeof el.getAnimations === 'function' 做特性检测,直接走 timer 分支,比 try/catch 干净。

reduce 那条我推荐先上 <MotionConfig reducedMotion="user">,framer-motion 会自动把 transform 类降级;条件渲染的话记得配 initial={false},否则 AnimatePresence 那层还会试图播一次。scrollbar-gutter Safari 支持得晚,回退用 padding-right: window.innerWidth - document.documentElement.clientWidth 补。

最后一点提醒:保底 timer 永远别省,它不是可选项。