Anime.js v4 × Remotion:createTimeline + Seek Bridge
Anime.js v4 是 2025 年底的大改版,用 createTimeline 取代舊版 anime.timeline。整合 Remotion 的關鍵:autoplay: false + tl.seek(ms),記得 Anime.js 用毫秒不是秒。
如果你看過上一篇 T28:GSAP × Remotion,這篇基本上是同一個 seek bridge 模式換另一個動畫引擎。但 Anime.js v4 有兩個會讓人踩雷的細節:毫秒 vs 秒、createTimeline vs anime.timeline。先把這兩件事釘在腦袋裡,後面就很順。
成品預覽
12 秒 4 階段:
- 0–3s:標題滑入 + 6 個圓 stagger 放大(
outBack) - 3–6s:圓變方 + snap 進 3×2 grid + 旋轉 360°(
outElastic) - 6–9s:離開 grid,沿正弦波形飄移橫越畫面(curve path)
- 9–12s:spiral 匯聚成中央脈衝光球 + "Anime.js × Remotion" 字幕
整支影片底下有 progress bar,數值直接讀 tl.currentTime / tl.duration。沒有任何 interpolate() 在做動畫運算,全部交給 Anime.js。
Anime.js v4 的重大改變
v3 → v4 是破壞性改版。如果你 google 到 2024 以前的 Anime.js 教學,那些 API 八成都不能用了:
| v3 | v4 |
|---|---|
anime.timeline(...) | createTimeline(...) |
timeline.add({targets, ...}) | tl.add(targets, {...}, position) |
easing: 'easeOutElastic' | ease: 'outElastic(1, .6)' |
| callback-based | Promise + callback |
default export (import anime from) | named exports (import { ... } from) |
這篇全部用 v4 API,跟舊版完全不相容,請注意你 package.json 裡的版本。
這篇會用到
animejs(v4.3.6 或以上)import { createTimeline } from 'animejs'- Remotion
useCurrentFrame+useVideoConfig - React
useMemo(讓 timeline 只 build 一次)
前置知識
- T28:GSAP × Remotion — seek bridge 模式總覽,這篇假設你已經知道為什麼要這樣做
Step 1:安裝套件
npm install animejs@^4
npm install --save-dev @types/animejs # 如果有 TS type 問題要特別指定 ^4,因為 npm 上 animejs 預設可能還會抓到 v3 的某些版本範圍。確認 node_modules/animejs/package.json 的版本是 4.x.x 才安全。
Step 2:建立 paused timeline
import { createTimeline } from 'animejs';
const tl = useMemo(() => {
const t = createTimeline({
autoplay: false, // ← 關鍵:不自動播
defaults: { duration: 800, ease: 'outExpo' },
});
return t;
}, []);差異比較:
- GSAP:
gsap.timeline({ paused: true }) - Anime.js v4:
createTimeline({ autoplay: false })
兩個結果一樣:timeline 被建立後不會自己跑,必須手動 seek() 才會有動作。這正是我們要的——讓 Remotion 的 frame 來掌控時間軸。
defaults 裡放共用的 duration / ease,這樣每次 tl.add() 都不用重複寫。
Step 3:定義狀態物件
跟 GSAP 範例一樣,用 plain object 當 animation targets。Anime.js v4 會直接 mutate 這些物件的 properties:
type ShapeState = {
x: number;
y: number;
r: number; // 半徑(圓階段)
size: number; // 邊長(方形階段)
rotate: number;
opacity: number;
morph: number; // 0 = 圓,1 = 方(控制 borderRadius)
glow: number; // box-shadow 強度
trail: number; // 拖尾透明度
};
const PALETTE = [
'#FF4B4B', '#FFAD5A', '#FFEE7F',
'#67EEAC', '#6A9CFF', '#B185F5',
] as const;
const state = useMemo(() => ({
shapes: PALETTE.map((_, i) => ({
x: 360 + i * 240,
y: 600,
r: 0,
size: 140,
rotate: 0,
opacity: 0,
morph: 0,
glow: 0,
trail: 0,
})),
title: { y: -120, opacity: 0, blur: 16 },
centerPulse: { scale: 0, opacity: 0, ringScale: 0, ringOpacity: 0 },
background: { hue: 0, vignette: 0.3 },
}), []);重點:state 是「資料」不是 React state。Anime.js 會在 seek() 內部直接改它的 numeric properties。我們的 JSX 只是在每個 frame 把這些數字讀出來畫成 div。
Step 4:用 tl.add() 加動畫
v4 的 add() 簽名:
tl.add(targets, params, position)實際用起來:
// 標題滑入
tl.add(
state.title,
{
y: 0,
opacity: 1,
blur: 0,
duration: 700,
ease: 'outExpo',
},
0, // ← position 是毫秒
);
// 6 個 shape 逐一放大(stagger by hand)
state.shapes.forEach((shape, i) => {
tl.add(
shape,
{
r: 70,
opacity: 1,
glow: 1,
duration: 700,
ease: 'outBack',
},
700 + i * 110, // 從 700ms 開始,每個間隔 110ms
);
});注意: position 參數是毫秒。GSAP 是秒,Anime.js 是毫秒。踩過這個雷的人舉手 🙋
第二階段也很單純,6 個 shape 各 snap 到 3×2 grid 上:
const GRID_ORIGIN_X = 660;
const GRID_ORIGIN_Y = 420;
const GRID_GAP = 220;
state.shapes.forEach((shape, i) => {
const col = i % 3;
const row = Math.floor(i / 3);
tl.add(
shape,
{
x: GRID_ORIGIN_X + col * GRID_GAP,
y: GRID_ORIGIN_Y + row * GRID_GAP,
size: 160,
morph: 1, // 0→1 觸發圓變方
rotate: 360,
duration: 1300,
ease: 'outElastic(1, .6)',
},
3000 + i * 60,
);
});注意 morph 從 0 跑到 1——這個欄位等下會被 JSX 拿來算 borderRadius。動畫值是真的在 0~1 之間慢慢補間,視覺上就會看到圓角逐漸縮小變方。
Step 5:Seek Bridge — 毫秒版本
整個整合的核心其實只有兩行:
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// ⚠️ 關鍵:frame / fps 是「秒」,要 × 1000 變毫秒
const timeMs = (frame / fps) * 1000;
tl.seek(timeMs, true); // 第二個 true = muteCallbacks,避免 onBegin 重複觸發跟 GSAP 比較:
// GSAP(用秒)
tl.seek(frame / fps);
// Anime.js v4(用毫秒)
tl.seek((frame / fps) * 1000);第二個參數 true 是 muteCallbacks——因為我們是「掃過時間軸」而不是「正常播放」,每個 frame 都會 seek 一次。如果不 mute,onBegin / onComplete 之類的 callback 會在你前後拖動時瘋狂亂噴。
Step 6:讀 tl.currentTime 和 tl.duration
v4 的 Timer class(Timeline 的父類)暴露兩個 runtime 屬性:
const currentMs = tl.currentTime; // 當下時間(ms)
const totalMs = tl.duration; // 總長度(ms)
const progress = currentMs / totalMs; // 0 ~ 1包一層 helper 防止除零:
const readProgress = (tl: ReturnType<typeof createTimeline>): number => {
const total = tl.duration || 1;
const current = tl.currentTime || 0;
const ratio = current / total;
if (!Number.isFinite(ratio)) return 0;
return Math.max(0, Math.min(1, ratio));
};然後就可以直接畫 progress bar:
<div
style={{
width: `${progress * 100}%`,
height: 8,
background:
'linear-gradient(90deg, #FF4B4B, #FFAD5A, #FFEE7F, #67EEAC, #6A9CFF, #B185F5)',
}}
/>連帶可以在畫面上印出原始毫秒數,方便 debug:
tl.currentTime: {Math.round(tl.currentTime || 0)}ms · tl.duration:{' '}
{Math.round(tl.duration || 0)}msStep 7:在 JSX 裡渲染 shapes
{state.shapes.map((s, i) => {
// morph 0 → 用半徑算尺寸(圓)
// morph 1 → 用 size 邊長(方)
const liveSize = s.morph < 0.5 ? Math.max(s.r * 2, 1) : s.size;
const borderRadius = interpolate(s.morph, [0, 1], [999, 18], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
return (
<div
key={`shape-${i}`}
style={{
position: 'absolute',
left: s.x - liveSize / 2,
top: s.y - liveSize / 2,
width: liveSize,
height: liveSize,
backgroundColor: PALETTE[i],
borderRadius,
transform: `rotate(${s.rotate}deg)`,
opacity: s.opacity,
boxShadow: `0 ${20 * s.glow}px ${60 * s.glow}px ${PALETTE[i]}66`,
}}
/>
);
})}這裡混用 Remotion 的 interpolate() 不是在做動畫——只是把 morph (0~1) 映射到 borderRadius 的視覺空間(999px 圓 → 18px 方角)。動畫本身還是 Anime.js 在驅動。
trails 同理,只是用 s.trail 做透明度淡入淡出,畫在 shape 後面:
{state.shapes.map((s, i) => {
if (s.trail <= 0.001) return null;
const trailSize = (s.morph < 0.5 ? s.r * 2 : s.size) * 1.4;
return (
<div
key={`trail-${i}`}
style={{
position: 'absolute',
left: s.x - trailSize / 2,
top: s.y - trailSize / 2,
width: trailSize,
height: trailSize,
borderRadius: '50%',
background: `radial-gradient(circle, ${PALETTE[i]}55, ${PALETTE[i]}00 70%)`,
opacity: s.trail * 0.85,
filter: 'blur(14px)',
}}
/>
);
})}Step 8:渲染
npx remotion render TutorialAnimeJsDemo out/t20.mp4 --codec=h264Composition 設定:1920×1080、30fps、360 frames(12 秒)。記得在 Root.tsx 把 TUTORIAL_ANIMEJS_DEMO_DURATION_FRAMES exported constant 拿來用,避免時長對不上 timeline 內部毫秒。
原理解析:為什麼 Anime.js 用毫秒?
Web Animations API(WAAPI)、CSS animation、setTimeout 通通用毫秒。Anime.js v4 為了跟 WAAPI 對齊(v4 內部會嘗試 sync 到 WAAPI 實例做硬體加速),整個 API 統一改成毫秒。GSAP 走自己的路,堅持用秒。
換算公式:
frame → seconds = frame / fps
seconds → milliseconds = seconds * 1000
∴ frame → ms = (frame / fps) * 1000
30fps 下:
- frame 30 = 1 秒 = 1000ms
- frame 90 = 3 秒 = 3000ms
- frame 360 = 12 秒 = 12000ms
如果你忘了 × 1000,會發生什麼?tl.seek(12) 對一個 12000ms 的 timeline,等於 0.1% 進度,你會看到的就是「畫面完全沒動」。這是 anime.js 整合最常見的 bug,而且超難 debug 因為沒有任何 error。
原理解析:v4 的 add() 第三參數 position
tl.add(targets, params, position) 的 position 支援多種寫法:
- 數字:絕對毫秒(從 timeline 0 開始)。例如
3000= 第 3 秒 '+=500':接在「上一段結束」之後 + 500ms'-=200':與上一段重疊 200ms(提前 200ms 開始)- label:先
tl.label('foo', 3000)再tl.add(..., 'foo')
本教學全部用絕對數字,因為 Remotion 要求「精確時間軸」,相對偏移在後期維護時很容易腦袋打結。
原理解析:v4 的 ease 字串語法
v4 的 ease 是 function-style 字串,比 v3 的 enum 更靈活:
'outExpo'— 等同 v3 的easeOutExpo'outBack'— 帶反彈'outElastic(1, .6)'— 彈性,帶參數(amplitude, period)'spring(1, 80, 10, 0)'— spring 物理模擬(mass, stiffness, damping, velocity)'cubicBezier(0.25, 0.1, 0.25, 1)'— 自訂貝茲曲線'inOutQuad'/'inOutSine'/'inOutCubic'— 標準雙向 ease
如果你忘了改 easing → ease(注意拼字!),v4 不會報錯,會直接用預設的 outExpo。又是一個超難察覺的雷。
GSAP vs Anime.js v4 選哪個?
| GSAP 3 | Anime.js v4 | |
|---|---|---|
| 時間單位 | 秒 | 毫秒 |
| 暫停方式 | paused: true | autoplay: false |
| Bundle size | ~70KB | ~30KB(更小) |
| 插件生態 | 巨大(ScrollTrigger / MotionPath / SplitText / DrawSVG…) | 較少但在長 |
| 收費 | 免費(商業曾需 Club 授權,現在全免費了) | MIT 一直免費 |
| TypeScript | 官方 types 完整 | v4 新增 types,但較粗糙 |
| 學習曲線 | 文件豐富、社群大 | v4 文件還在補齊中 |
| API 風格 | 流暢鏈式 | v4 更接近 WAAPI |
實務建議:
- 已經熟 GSAP → 繼續用,沒必要為了 30KB 整套重學
- 新專案、只想做 Remotion 影片動畫 → 兩個都行,挑你看得順眼的
- 擔心 bundle size → Anime.js
- 要 ScrollTrigger 等進階插件 → GSAP(雖然 Remotion 渲染裡用不到 ScrollTrigger,但其他插件像 MotionPath 還是有用)
- 要長期維護、團隊新人多 → GSAP,文件和 stackoverflow 答案數量輾壓
常見問題
Q:v4 沒有 default export 了?
對。從 import anime from 'animejs' 變成 import { createTimeline, animate, stagger } from 'animejs'。v3 的程式碼整個要改 import 寫法,這也是為什麼很多舊範例直接搬過來會壞。
Q:v3 的 anime.timeline({ easing: 'linear' }) 在 v4 怎麼寫?
createTimeline({ defaults: { ease: 'linear' } });兩個變動:(1) 參數名從 easing 改成 ease,(2) 共用設定要包在 defaults 裡面。
Q:為什麼我 seek 後看不到動畫?
99% 是 frame / fps 沒 × 1000。你的 timeline 有 12000ms,但你 seek 到 12(也就是 0.1%),畫面當然沒動。用毫秒!毫秒!毫秒!(踩雷 3 次後就會記住)
Q:TypeScript 報 Timeline type 找不到?
v4 的 type export 命名跟 v3 不一樣。最穩的寫法是用 ReturnType:
const tl: ReturnType<typeof createTimeline> = useMemo(() => { ... }, []);或如果你的 v4 版本有 export,可以 import type { Timeline } from 'animejs'。視套件版本而定。
Q:tl.seek() 第二個參數是什麼?
muteCallbacks。傳 true 表示這次 seek 不要觸發 onBegin / onComplete / onUpdate 等 callback。Remotion 渲染時會反覆 seek 到不同 frame,如果不 mute,callback 會被瘋狂亂呼叫。
Q:可以在同一個 Composition 裡混用 GSAP 跟 Anime.js 嗎?
技術上可以,兩個 timeline 各自 build、各自 seek,不會打架。但實務上建議擇一,混用容易讓未來的自己看不懂哪個動畫是誰在驅動。
下一步
- T21:Three.js + React Three Fiber — 把 3D 場景放進 Remotion 影片,包含 camera 動畫、PBR 材質、後製合成