GSAP 依然是 Web 上功能最强的动画库,而且从 3.13 版本开始,所有插件——ScrollTrigger、SplitText、MorphSVG,全部——都免费了。但 React 会渲染、重新渲染、卸载组件,并且(在开发模式下)会把副作用执行两次。一个直接操作 DOM 的动画库要融入进来,需要一点技巧。
下面是我在 Next.js App Router 项目中使用的方案,本站也是如此。
安装
npm install gsap @gsap/react@gsap/react 提供 useGSAP hook,它处理了大家最容易出错的部分:清理。
基本用法
'use client'
import { useRef } from 'react'
import gsap from 'gsap'
import { useGSAP } from '@gsap/react'
gsap.registerPlugin(useGSAP)
export function Hero() {
const container = useRef<HTMLDivElement>(null)
useGSAP(
() => {
gsap.from('.hero-line', { yPercent: 100, opacity: 0, stagger: 0.08, duration: 0.9, ease: 'power3.out' })
},
{ scope: container },
)
return (
<div ref={container}>
<h1 className="hero-line">Design</h1>
<h1 className="hero-line">and build</h1>
</div>
)
}这里发生了三件事:
'use client'。 GSAP 要操作 DOM,所以组件必须在浏览器中运行。让动画部分尽量小,页面其余部分保持为服务端组件。scope: container。 像.hero-line这样的选择器只会匹配当前组件内部的元素,两个实例永远不会互相影响。- 自动清理。 回调中创建的所有补间、时间轴和 ScrollTrigger,都会在组件卸载时还原。这也是为什么 React 开发模式下的双重调用不会留下重复的动画。
数据变化时重新执行
传入 dependencies 让它在数据变化时重新执行;如果需要先撤销上一次的效果,再加上 revertOnUpdate:
useGSAP(
() => {
gsap.to('.bar', { scaleX: progress, transformOrigin: 'left', duration: 0.4 })
},
{ scope: container, dependencies: [progress] },
)事件处理函数:contextSafe
在 hook 执行之后创建的动画——比如在点击事件里——不会被自动记录下来以便清理。用 contextSafe 包裹这些函数:
const { contextSafe } = useGSAP({ scope: container })
const onEnter = contextSafe((event: React.MouseEvent<HTMLElement>) => {
gsap.to(event.currentTarget, { scale: 1.04, duration: 0.3 })
})
return <button onMouseEnter={onEnter}>Hover me</button>ScrollTrigger
注册一次即可(在模块顶层调用没问题),然后在 useGSAP 里创建触发器:
'use client'
import { useRef } from 'react'
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
import { useGSAP } from '@gsap/react'
gsap.registerPlugin(useGSAP, ScrollTrigger)
export function RevealSection({ children }: { children: React.ReactNode }) {
const ref = useRef<HTMLElement>(null)
useGSAP(
() => {
gsap.utils.toArray<HTMLElement>('.reveal').forEach((el) => {
gsap.from(el, {
y: 40,
opacity: 0,
duration: 0.8,
ease: 'power2.out',
scrollTrigger: { trigger: el, start: 'top 85%' },
})
})
},
{ scope: ref },
)
return <section ref={ref}>{children}</section>
}固定与跟随滚动
useGSAP(
() => {
gsap.timeline({
scrollTrigger: {
trigger: '.story',
start: 'top top',
end: '+=200%',
scrub: 1, // follows the scrollbar, with 1 s of smoothing
pin: true, // holds the section while the timeline plays
},
})
.to('.story-image', { scale: 1.2 })
.to('.story-text', { yPercent: -50 }, '<')
},
{ scope: ref },
)布局变化后刷新
ScrollTrigger 在创建时测量位置。如果之后图片、字体或延迟加载的内容改变了页面高度,开始和结束位置就会偏移。等它们加载完成后调用 ScrollTrigger.refresh():
document.fonts.ready.then(() => ScrollTrigger.refresh())给图片设置明确的尺寸(或 aspect-ratio)能从源头避免大部分此类问题。
Next.js 特有的坑
路由切换和滚动位置
在 App Router 中,页面间导航会卸载旧页面的组件(于是 useGSAP 清理了它们的触发器),并挂载新页面的组件。如果导航后发现触发器在错误的位置触发,通常是因为新页面在图片加载完成前就做了测量——像上面那样在加载后刷新即可。
服务端组件不能做动画
服务端组件里不能使用 useGSAP。不要把整个页面变成客户端组件,而是只用一个小的客户端包装组件包住动画部分,把服务端渲染的内容作为 children 传进去。内容保留在 HTML 中(对 SEO 和 LCP 都好),动效叠加在上面。
避免未动画内容的闪烁
gsap.from() 在水合之后才运行,所以会有一帧元素显示为最终状态,然后跳到起始状态。两种解决办法:
- 使用
gsap.fromTo(),并用 CSS 设置起始状态(样式表中写opacity: 0),同时接受这个元素要依赖 JavaScript 才能显示——只适用于非关键元素。 - 对首屏内容更好的做法:从可见状态开始动画(小幅
y位移,不改变透明度),这样首次绘制就是正确的。你的 LCP 会感谢你——见动画网站的 Core Web Vitals 优化。
无障碍:matchMedia
gsap.matchMedia() 让你只在媒体查询匹配时创建动画,并在不再匹配时自动还原:
useGSAP(
() => {
const mm = gsap.matchMedia()
mm.add('(prefers-reduced-motion: no-preference)', () => {
gsap.from('.card', { y: 60, opacity: 0, stagger: 0.1, scrollTrigger: '.cards' })
})
},
{ scope: container },
)偏好减少动态效果的访客会直接看到内容,没有运动效果。同样的 API 也能处理只在桌面端生效的效果('(min-width: 768px)')。
性能建议
- 对
x、y、scale、rotation和opacity做动画,GSAP 会把它们映射为 transform。 - 对持续更新的值(比如跟随光标的元素)使用
gsap.quickTo()——它复用同一个补间,而不是每次事件都新建一个。 - 控制同时存在的 ScrollTrigger 数量,相似元素用
ScrollTrigger.batch()批量处理。
总结
- 每个动画组件都用带
scope的useGSAP - 事件处理中创建的动画用
contextSafe - 插件注册一次,ScrollTrigger 在
useGSAP里创建 - 字体和图片加载完成后刷新 ScrollTrigger
- 内容放在服务端组件中,用客户端包装组件添加动画
- 用
gsap.matchMedia()尊重prefers-reduced-motion

