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

