GSAP × Remotion:用 Seek Bridge 模式把 GSAP Timeline 搬進影片
設計師常用的 GSAP 動畫函式庫 + Remotion = 強大的組合。用 tl.pause() 建立一個 paused timeline,在 useCurrentFrame 驅動下 tl.seek(frame/fps),GSAP 的所有 ease、stagger、timeline 語法都能直接用在影片裡。
成品預覽
15 秒展示 GSAP seek bridge:5 個方塊經歷 5 個 phase(stagger entry → sine wave → scatter & reform → pulse → converge to「GSAP」)。底部 progress bar 即時顯示 tl.progress(),左上角 timecode 顯示 frame / fps,方便驗證 GSAP timeline 與 Remotion frame clock 是否完美同步。
整段影片裡 沒有任何一個 interpolate() 呼叫——所有的位移、ease、stagger、顏色循環,全部都是 GSAP timeline 在做事;Remotion 只負責提供 frame 數字。
為什麼要 GSAP × Remotion?
Remotion 自己的 interpolate() 已經很強,但它的設計哲學是「一段動畫 = 一段程式碼」。當你想做一個 5 秒、5 個 phase、20 個物件交錯出場的 choreography,會發現:
- 每個物件的進場時間要自己手算 frame
- stagger 要自己寫
forloop + delay - elastic / back / bounce 這類 ease 要自己定義
- 「在某段動畫之後 0.5 秒接下一段」要自己維護 offset 變數
而 GSAP 的 timeline 就是為了解決這些問題而生的:
stagger: 0.1一行搞定多物件延遲- 30+ 內建 ease,字串就能呼叫
- 絕對/相對時間定位(
"+=0.5"、"<"、label) - nested timeline,可以把一段動畫包成函式重複用
更重要的是:設計師早就會用 GSAP 了。如果你的團隊有平面/網頁動效設計師,他們不需要重學 Remotion 的 API,只要把熟悉的 GSAP 程式碼丟進 Remotion 的 component 就能渲染成影片。
唯一要解決的問題就是:GSAP 預設用 wall-clock 跑動畫,而 Remotion 是 deterministic renderer——同一個 frame 不管渲染幾次都必須產生同一張畫面。這兩件事看似衝突,但其實 GSAP 早就提供了解法,也就是這篇要講的 seek bridge 模式。
核心原理:Seek Bridge 模式
關鍵洞察:GSAP 的 timeline 有兩種「推進時間」的方式:
- autoplay:自己跑
requestAnimationFrame,時間來源是瀏覽器的 wall clock - seek(time):外部告訴它「現在時間是 X 秒」,它立刻跳到那一幀的狀態
Remotion 是 deterministic renderer,同一個 frame 必須永遠產生同一張畫面。autoplay 模式完全不行——render 時 wall clock 不會跟 frame 對齊,且 Remotion 是並行渲染多個 frame,每個 frame 都會掛載一份 component,autoplay 完全沒機會跑起來。
但 seek(time) 完美契合:
tl.seek(frame / fps, false);
// ^^^^^^^^^^^ 把 Remotion 的幀數換算成秒,餵給 GSAP
// ^^^^^ muteCallbacks第二個參數 false 是 muteCallbacks——意思是「不要 mute callback」。在 render 時其實建議傳 true,因為同一個 frame 可能會被反覆 seek 多次(preview、render、re-render),onUpdate / onComplete 跑 N 次會讓人困惑。但本範例沒用到 callback,傳什麼都一樣。
這個模式之所以叫 bridge,是因為 GSAP 的時間軸與 Remotion 的 frame clock 原本是兩個獨立系統,而 frame / fps 這條算式就是橋——一邊輸入 frame,另一邊輸出 GSAP 的 internal time。
這篇會用到
gsapv3.14.2(或任何 GSAP 3 版本)- Remotion 的
useCurrentFrame+useVideoConfig - React
useMemo確保 timeline 只建一次
不需要 GSAP 的任何 plugin(ScrollTrigger / Draggable / MotionPath 等等都不能用,後面會解釋)。
Step 1:安裝套件
npm install gsapGSAP 3 是 MIT License 免費的,商用也沒問題。Plugin(ScrollTrigger 例外,那也是 free)才需要 Club GreenSock。
Step 2:建立 paused timeline
import {gsap} from 'gsap';
import {useMemo} from 'react';
const tl = useMemo(() => {
const t = gsap.timeline({
paused: true,
defaults: {ease: 'power2.out'},
});
// 後面 step 會逐步加動畫
return t;
}, []);關鍵字:paused: true。沒加這個,GSAP 會立刻用 autoplay 跑,Remotion 渲染時就會出現不同步。
defaults 是 GSAP 的小技巧——你在這裡定義的屬性會自動套用到所有後續加進來的 tween,除非那段 tween 自己覆寫。一次設定 ease,整條 timeline 都吃。
useMemo 的 dependency 是空陣列 [],意味著「只在掛載時建立一次」。為什麼這樣可以、為什麼必須這樣,最後一節會詳細解釋。
Step 3:動畫「純物件」而不是 DOM
GSAP 的動畫目標可以是任何有數字屬性的 JavaScript 物件——不限 DOM element。這點對 Remotion 來說是天大的好消息,因為 Remotion 是 React-first 的,我們不會去碰 document.querySelector。
所以策略是:把所有要動畫的數值放在一個 plain object 裡,讓 GSAP mutate 它,然後 React 在 JSX 裡讀這個物件畫出來。
type BoxState = {
x: number;
y: number;
scale: number;
rotation: number;
opacity: number;
hue: number;
saturation: number;
lightness: number;
borderRadius: number;
};
type AnimState = {
boxes: BoxState[];
title: {y: number; opacity: number; letterSpacing: number};
subtitle: {y: number; opacity: number};
finalLabel: {opacity: number; scale: number};
bgGlow: {opacity: number; hue: number};
phaseLabel: {opacity: number; y: number; text: string};
};
const state = useMemo<AnimState>(
() => ({
boxes: Array.from({length: 5}, (_, i) => ({
x: initialBoxX(i),
y: 1280, // 從畫面底下開始
scale: 1,
rotation: 0,
opacity: 0,
hue: 200 + i * 30,
saturation: 75,
lightness: 58,
borderRadius: 22,
})),
title: {y: -80, opacity: 0, letterSpacing: 0},
subtitle: {y: 30, opacity: 0},
finalLabel: {opacity: 0, scale: 0.7},
bgGlow: {opacity: 0, hue: 210},
phaseLabel: {opacity: 0, y: 16, text: 'Phase 1 / Stagger Entry'},
}),
[],
);注意 boxes 的初始 y: 1280——這就是動畫的起點,待會 GSAP 會把它從畫面下方拉到中間。opacity: 0 也是同樣道理。GSAP 的 tl.to(target, {y: 500}) 是「從目前狀態 tween 到 500」,所以這個 plain object 的初始值就決定了動畫的起始畫面。
Step 4:加動畫到 timeline
現在開始把 timeline 的內容填進去。我們把 5 個 phase 全部串起來,每個 phase 約 3 秒:
const buildTimeline = (state: AnimState) => {
const tl = gsap.timeline({
paused: true,
defaults: {ease: 'power2.out'},
});
// ===== Phase 1 (0 - 3s): Stagger entry =====
tl.to(state.title, {y: 0, opacity: 1, letterSpacing: 6, duration: 0.7}, 0);
tl.to(state.subtitle, {y: 0, opacity: 1, duration: 0.6}, 0.35);
tl.to(state.bgGlow, {opacity: 0.35, duration: 1.2}, 0);
tl.to(
state.boxes,
{
opacity: 1,
y: midY - BOX_SIZE / 2,
duration: 0.65,
stagger: 0.1,
ease: 'back.out(1.6)',
},
0.5,
);重點解讀:
- 第三個參數(
0、0.35、0.5)是「絕對時間位置」,單位是秒。GSAP 預設行為是「接在上一段後面」,但我們想要多段並行 + 精確控制時,傳絕對位置最直觀 stagger: 0.1把state.boxes這個陣列當成 5 個獨立 target,每個延遲 0.1 秒啟動。如果用 Remotion 的interpolate()寫,至少要一個forloop + 5 個interpolate呼叫ease: 'back.out(1.6)'字串 ease 是 GSAP 的招牌語法。back表示衝過頭再回彈,1.6是回彈強度
接著 Phase 2 用正弦波讓方塊上下擺動:
// ===== Phase 2 (3 - 6s): Sine wave =====
tl.to(
state.boxes,
{
y: (i: number) => midY - BOX_SIZE / 2 + Math.sin(i * 0.9) * 110,
x: (i: number) => initialBoxX(i),
hue: (i: number) => 180 + i * 25,
saturation: 80,
rotation: (i: number) => (i % 2 === 0 ? 8 : -8),
duration: 1.2,
ease: 'sine.inOut',
stagger: 0.05,
},
3.0,
);注意 y: (i) => ... 這個寫法——GSAP 的 function-based values,當 target 是陣列時,每個元素會收到自己的 index,函式回傳的值就是該元素的目標。可以根據 index 算出不一樣的位置、顏色、旋轉,超實用。
Phase 3、4、5 長相類似,篇幅關係只列出最具代表性的 Phase 5(收斂成「GSAP」字樣):
// ===== Phase 5 (12 - 15s): Convergence =====
tl.to(
state.boxes,
{
x: (i: number) => finalLetterX(i),
y: (i: number) => finalLetterY(i),
scale: (i: number) => (i === 4 ? 0.4 : 1.05),
rotation: 0,
borderRadius: (i: number) => (i === 4 ? 60 : 20),
duration: 1.2,
ease: 'power3.inOut',
stagger: {each: 0.06, from: 'start'},
},
12.0,
);
// Hard pin the timeline length to exactly 15s.
tl.set({}, {}, 15);
return tl;
};最後一行 tl.set({}, {}, 15) 是個小技巧:往空物件設空屬性,但時間位置是 15 秒。用途是「強制把 timeline 的總長度釘在 15 秒」,避免最後一段動畫提早結束導致 progress 永遠到不了 1。
Step 5:Seek Bridge — 最關鍵的一行
現在 timeline 建好了,但它還是 paused 的。要讓它跟著 Remotion 的 frame 走,只需要兩行:
const frame = useCurrentFrame();
const {fps} = useVideoConfig();
tl.pause(); // 保險再 pause 一次
tl.seek(frame / fps, false);
// 現在 state.boxes[0].x、state.title.opacity 等都是這一幀的值為什麼這樣能行?拆解一下執行順序:
- Remotion 每一幀都會重新呼叫整個 component function(這是 React 的標準行為)
useMemo保證state和tl只建立一次(dependency 是[]),不會被重建tl.seek(t)是 同步呼叫——它會立刻 mutatestate的所有屬性到t秒對應的狀態- 緊接著 JSX 開始 render,讀
state.boxes[0].x拿到的就是正確的這一幀數值 - 同一個 frame 永遠產生同一結果(因為
frame / fps是純函式輸入)→ Remotion 可以並行渲染、可以隨意跳轉預覽
整個 bridge 的精妙在於:GSAP 完全不知道自己在 Remotion 裡跑。它以為自己只是被一段陌生程式碼呼叫了 seek(),按照 API 定義回傳結果——而我們利用這個「seek 是同步、結果可預測」的特性,把它變成 Remotion 的計算引擎。
Step 6:在 JSX 裡讀 state 畫出來
剩下的就是普通的 React 渲染。所有的數字都從剛才被 GSAP mutate 過的 state 物件讀出來:
return (
<AbsoluteFill style={{backgroundColor: '#0a0f1c'}}>
{/* Title */}
<div
style={{
position: 'absolute',
top: 110,
left: 0,
right: 0,
textAlign: 'center',
fontSize: 110,
fontWeight: 900,
letterSpacing: state.title.letterSpacing,
transform: `translateY(${state.title.y}px)`,
opacity: state.title.opacity,
}}
>
GSAP Timeline
</div>
{/* Animated boxes */}
{state.boxes.map((box, i) => (
<div
key={i}
style={{
position: 'absolute',
left: box.x,
top: box.y,
width: 140,
height: 140,
borderRadius: box.borderRadius,
background: `hsl(${box.hue}, ${box.saturation}%, ${box.lightness}%)`,
transform: `scale(${box.scale}) rotate(${box.rotation}deg)`,
opacity: box.opacity,
}}
/>
))}
</AbsoluteFill>
);整段沒有任何 interpolate()、沒有任何 useEffect、沒有任何 ref。每一幀的視覺都是「state 被 seek mutate → JSX 讀 state」這個單向流程的結果。
Step 7:用 tl.progress() 做 progress bar
GSAP 的 tl.progress() 回傳當下的進度(0 到 1),這是免費送的視覺驗證工具:
const progress = tl.progress(); // 0 ~ 1
const timecode = (frame / fps).toFixed(2);
<div
style={{
position: 'absolute',
bottom: 90,
left: 140,
right: 140,
height: 8,
backgroundColor: 'rgba(148,163,184,0.2)',
borderRadius: 4,
overflow: 'hidden',
}}
>
<div
style={{
width: `${progress * 100}%`,
height: '100%',
background: 'linear-gradient(90deg, #60a5fa 0%, #a855f7 50%, #ec4899 100%)',
}}
/>
</div>
<div style={{position: 'absolute', bottom: 110, right: 140, fontSize: 20}}>
tl.progress() = {progress.toFixed(3)} · t = {timecode}s
</div>這個 bar 不只是給觀眾看的——它同時是 debug 工具。如果 seek 沒生效(譬如忘了 paused: true、或 useMemo deps 寫錯),progress 會永遠停在 0 或亂跳。一眼就能看出來。
Step 8:渲染
跟一般 Remotion composition 一樣:
npx remotion render TutorialGsapDemo out/t19.mp4 --codec=h264GSAP 在 Node.js 環境(Remotion render worker)也能跑,因為它本來就支援動畫純物件,不依賴 DOM。
原理解析:為什麼 useMemo 的 dependency 是空陣列?
這是整個模式裡最容易踩坑的地方。三種寫法的差異:
// (A) 錯:每幀都重建
const tl = useMemo(() => buildTimeline(state), [frame]);
// (B) 錯:每次 state 變就重建(state 其實也應該是 useMemo([]))
const tl = useMemo(() => buildTimeline(state), [state]);
// (C) 對:只建立一次
const tl = useMemo(() => buildTimeline(state), []);寫法 (A) 每幀都重建 timeline,會發生三件慘事:
- 超慢:建立 timeline 是 O(動畫段數),每幀都跑一次直接拖垮 render
- state 被重設:因為
state也會跟著重建,你會看到 box 永遠在初始位置 - seek 沒意義:剛建好的 timeline seek 到 X 秒,下一幀又是全新的 timeline 再 seek 到 Y 秒,沒有任何「保留狀態」的概念
寫法 (C) 才對:state 和 tl 都只建立一次。關鍵洞察:state 物件本身永遠是同一個 reference,GSAP 是在 mutate 它的屬性而不是重新指派——React 完全不需要重建任何東西。
實際上 (B) 在這個範例也能跑(因為 state 用 useMemo([]) 也是只建一次),但保險起見直接寫 []。
Remotion 的 Composition mount 之後不會 unmount(除非你切到別的 composition),所以 useMemo 的快取是穩定的,可以放心用。
原理解析:muteCallbacks = true vs false
tl.seek(time, muteCallbacks) 第二個參數是 boolean,控制 seek 過程中是否觸發 callback:
true(mute):不觸發 onUpdate / onComplete / onStart 等任何 callbackfalse(不 mute):正常觸發
Remotion 的 render 行為意味著「同一個 frame 可能被 seek 很多次」(preview 拖動、render 並行、re-render hot reload),如果你的 timeline 有 callback:
tl.to(state.box, {
x: 500,
onComplete: () => console.log('done!'), // 會被呼叫 N 次
});那 console 會被刷爆,更慘的是如果 callback 有 side effect(呼叫 API、改 React state),會出現難以追蹤的 bug。
正式專案建議傳 true。本教學範例沒有用任何 callback,所以傳什麼都一樣,但養成習慣比較安全。
原理解析:GSAP vs 純 Remotion interpolate
什麼時候該用 GSAP,什麼時候直接用 Remotion 內建?
| 功能 | Remotion interpolate | GSAP Timeline |
|---|---|---|
| 單段簡單動畫 | 更簡潔 | 殺雞用牛刀 |
| Stagger(多物件延遲) | 要手寫 loop + delay | stagger: 0.1 一行 |
| 複雜 ease (elastic / back) | 要自己寫或用 @remotion/spring | 內建 30+ ease |
相對時間定位 ("+=0.5") | 要自己算 frame | 字串語法 |
| Nested timelines | 沒有原生概念 | 支援 |
| Function-based values | 沒有 | 支援 |
| 檔案大小 | 0(內建) | +70KB |
| TypeScript 型別 | 完整 | 不錯但有些 ease 字串沒檢查 |
建議:
- 場景簡單、動畫單一 → 用
interpolate(),不要為了 70KB 引入 GSAP - 多 phase 編排、需要 stagger / 複雜 ease → 用 GSAP timeline
- 已經有設計師寫好的 GSAP 程式碼 → 直接搬,省下重寫時間
兩者也可以混用——一個 component 裡面,背景的簡單淡入用 interpolate(),前景的複雜 choreography 用 GSAP,完全沒問題。
常見問題
Q:autoplay: false 跟 paused: true 有差嗎?
有差,但建議兩個都加。autoplay: false 是 GSAP 3 才加的選項,paused: true 是更老更通用的寫法。組合起來最保險:
gsap.timeline({paused: true, autoplay: false});Q:seek 後 opacity 沒生效?
通常是把整個 state 物件傳給 tl.to 而不是子物件。GSAP 會試圖去動畫 state.boxes、state.title 這些 key(值是物件,不是數字),結果什麼都沒發生。正確寫法是分開傳:
// 錯
tl.to(state, {opacity: 1});
// 對
tl.to(state.title, {opacity: 1});
tl.to(state.boxes, {opacity: 1, stagger: 0.1}); // 陣列當 targetsQ:畫面跳來跳去?
檢查 timeline 的總長度是否超過 composition 的 durationInFrames / fps。超過會導致最後幾秒 progress > 1,GSAP 會把 state seek 到一個未定義的狀態(通常是最後一幀的延伸)。用 tl.set({}, {}, durationSeconds) 強制釘住長度。
Q:可以用 GSAP 的 ScrollTrigger / Draggable / MotionPath plugin 嗎?
ScrollTrigger 不行——它依賴 wall clock 和 DOM scroll event,Remotion render 環境兩個都沒有。Draggable 也不行(沒有 mouse event)。要在 Remotion 做 scroll-like 效果,直接用 frame 算 translateY 即可,反而更精確。
MotionPath plugin 理論上可以(它只是把 SVG path 轉成數值),但需要 DOM SVG element 解析路徑,在 server-side render 會出問題。安全做法是改用 gsap.utils.toArray + 純物件做路徑插值。
Q:GSAP timeline 太長想分割成多段?
可以用 gsap.timeline() 巢狀:
const phase1 = gsap.timeline();
phase1.to(state.title, {opacity: 1});
phase1.to(state.boxes, {y: 500, stagger: 0.1});
const main = gsap.timeline({paused: true});
main.add(phase1, 0);
main.add(phase2, 3);子 timeline 不需要 paused: true,只要 main 是 paused 就會凍住所有 child。
本篇涵蓋的觀念
- GSAP timeline 的
paused: true模式 tl.seek(time, muteCallbacks)API 的語意tl.progress()讀當下進度,順便 debug- 用 plain JavaScript object 當 animation target
useMemo([])確保 timeline 與 state 只建立一次- Remotion
useCurrentFrame× GSAPseek的 bridge 模式 - function-based values + stagger 做多物件 choreography
- 為什麼 GSAP plugin(ScrollTrigger / Draggable)不能用
下一步
- T20:Anime.js v4 × Remotion — 另一個輕量級動畫庫,看看
anime.jsv4 全新的 timeline API 如何接上 Remotion - T21:Three.js + React Three Fiber 3D 場景 — 把整個 3D scene graph 也接上 frame clock,渲染出真正的 3D 影片