Kinetic Typography:10 種文字動畫 Preset 一次做到會
用純 React + interpolate + spring 刻 10 種文字進退場動畫。Typewriter、Scramble、Word Stagger、3D Rotate、Liquid Mask、Explode、RGB Glitch、Elastic Pop、Wave、Morph — 每個都是 40 行以內的獨立 preset。
成品預覽
這支 15 秒影片把 10 種常見的 kinetic typography preset 一次播完,每種 1.5 秒,自動換場、左上角有名字、底下有 progress bar。看完這篇你會發現,每個 preset 的核心邏輯其實都不到 40 行。
| # | Preset | 核心技術 |
|---|---|---|
| 01 | Typewriter | slice(0, n) + 閃爍游標 |
| 02 | Scramble | mulberry32 + 逐字 lock |
| 03 | WordStagger | spring + delay |
| 04 | Rotate3D | rotateX + perspective |
| 05 | LiquidMask | clip-path: inset(...) |
| 06 | SplitExplode | radial offset + rotate |
| 07 | GlitchSplit | 三層 + mix-blend-mode |
| 08 | ElasticPop | spring 低 damping |
| 09 | Wave | Math.sin(frame + i) |
| 10 | Morph | blur + scale crossfade |
這篇會做出什麼
10 個 preset × 45 frames(1.5 秒)× 30fps = 450 frames 的單一 composition。每個 preset 都是獨立 React 元件,包在自己的 <Sequence from={i*45} durationInFrames={45}> 裡播放,所以彼此完全不會互相干擾,內部用 useCurrentFrame() 拿到的就是相對於這個 Sequence 的 0~44,可以複製貼上到任何專案、任何位置。
更重要的是,這 10 個 preset 拆解出來的模式(逐字 stagger、spring 過衝、clip-path mask、隨機種子、層疊 blend mode),就是 90% 動態文字動畫的全部底層邏輯。學會這 10 個,你大概就再也不需要去網路上找其他文字動畫範例了。
前置知識
- T01 環境安裝 — 把專案跑起來
- T2 第一支 AI 影片 — 知道怎麼跟 Claude Code 一起寫 composition
- T3 YouTube 片頭動畫 — 熟悉
<Sequence>跟interpolate - T04 spring 物理動畫 — 熟悉
spring()的 damping / stiffness - T05 文字進退場基礎 — 字級/顏色/字距的基本工
整篇只會用到四個 Remotion API:useCurrentFrame、interpolate、spring、<Sequence>。其他都是純 React + CSS。
原理解析:Kinetic Typography 的三個核心技術
要先把這三個觀念吃進去,後面的 10 個 preset 才會看起來像「同一招的變奏」。
1. Frame-relative animation in <Sequence>
每個 preset 元件內 useCurrentFrame() 拿到的是相對於外層 Sequence 的 frame,不是大時間軸的 frame。preset 永遠從 0 開始算自己的時間,這是 Remotion 設計上最舒服的一件事——你寫元件的時候,不用知道自己被擺在哪。
{PRESETS.map((Preset, i) => (
<Sequence key={i} from={i * 45} durationInFrames={45}>
<Preset />
</Sequence>
))}Preset 元件裡 useCurrentFrame() 第一幀就是 0、第 44 幀就是 44。寫 preset 時永遠假設「我從 0 開始」,剩下的交給外層 Sequence。
2. Deterministic seeded random(mulberry32)
Remotion 的渲染模型是每一幀都重新呼叫元件函式——直接 Math.random() 的話,每次渲染都會得到不同結果,畫面會閃、渲染後跟 preview 對不上。解法是用 seeded random,每幀的種子是可預測的:
const mulberry32 = (seed: number) => {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
};用 frame 當種子 → 每幀變化(glitch 抖動、scramble 亂碼)。用 index 當種子 → 同一個粒子在每幀都有相同隨機屬性(粒子大小、初始角度)。兩種用法在後面的 preset 會大量出現。
3. Progress-based vs spring-based easing
interpolate 適合「我要在 X 幀內從 A 到 B,固定速率」這種需求;spring 適合「物理上自然的彈跳、過衝感」。看比較:
// interpolate:固定 30 幀內 0→1,最後一幀剛好到 1
const t = interpolate(frame, [0, 30], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
// spring:會自然過衝、回彈,看起來「有重量」
const s = spring({
frame,
fps: 30,
config: { damping: 8, stiffness: 140 },
durationInFrames: 40,
});對齊節奏的揭示用 interpolate(Liquid Mask、Morph 都是)。Q 彈、卡通、有彈性的 pop 用 spring(ElasticPop、WordStagger、SplitExplode 都是)。混用也行——常見招式是 spring() 算出 0~1,再 interpolate(s, [0,1], [from,to])。
Step 1:Typewriter — 逐字打字 + 閃爍游標
最經典的開場:字一個一個冒出來,後面跟著黃色閃爍游標。
新增 Composition KineticTypoDemo (1920x1080 / 30fps / 450 frames)
新增 TypewriterPreset:單字 "REMOTION",每幀算 visible = floor(frame * len/32),
slice(0, visible) 顯示。後面接 14×160 黃色方塊當游標,每 6 幀閃一次。
const visible = Math.min(word.length, Math.floor(frame * word.length / 32));
const cursorOn = Math.floor(frame / 6) % 2 === 0;
return (
<div style={{ display: "flex", alignItems: "center" }}>
<span>{word.slice(0, visible)}</span>
<span style={{ opacity: cursorOn ? 1 : 0 /* 黃色方塊 */ }} />
</div>
);關鍵是 slice(0, visible)——每幀只算「該顯示幾個」然後切字串。游標的閃爍頻率是 30fps ÷ 6 ÷ 2 = 2.5Hz,視覺上剛好像真的 terminal。
Step 2:Scramble — 隨機亂碼解開
字元先是隨機亂碼,然後從左到右一個一個 lock 成正確字母。Hacker 風。
新增 ScramblePreset:單字 "ANIMATE",30 幀內全部 lock。
每個字元 lock 時間 = i × (30/len)。未 lock 的字元用 mulberry32(frame*991+7)
從 "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#@$%&*" 取隨機字元。
已 lock 白色,未 lock 青藍色 + 發光。
const perChar = 30 / word.length;
const rand = mulberry32(frame * 991 + 7);
const chars = word.split("").map((target, i) => {
if (frame >= i * perChar) return target;
return SCRAMBLE_CHARS[Math.floor(rand() * SCRAMBLE_CHARS.length)];
});種子用 frame * 991 + 7——乘質數 + 加偏移是常見的 hash 技巧,避免相鄰幀產生相似亂數。每個字元寬度要設 minWidth: 110,不然亂碼跳字時整行寬度會跳來跳去。
Step 3:WordStagger — 多行文字錯開冒出
兩行字依序從下往上彈進來,每行用 spring 而且有 delay。
新增 WordStaggerPreset:words = ["KINETIC", "TYPOGRAPHY"]。每行 delay = i*8,
spring(damping:14, stiffness:110),translateY: 120→0,opacity 綁 spring 值。
{words.map((w, i) => {
const s = spring({
frame: frame - i * 8, fps,
config: { damping: 14, stiffness: 110 },
durationInFrames: 28,
});
const ty = interpolate(s, [0, 1], [120, 0]);
return <div style={{ transform: `translateY(${ty}px)`, opacity: s }}>{w}</div>;
})}frame - delay 是 stagger 的核心——把時間軸往後推 delay 幀。spring() 收到負數會自動回傳 0,所以 delay 期間元素會保持初始狀態。
Step 4:Rotate3D — 每個字 X 軸翻轉
每個字元像翻牌一樣繞 X 軸轉 180 度進場,奇偶字元配不同顏色。
新增 Rotate3DPreset:單字 "MOTION",外層 perspective: 1200。
每個字元 delay = i*4,spring 0~1 interpolate 成 rotateX: -180→0。奇偶白/天藍交替。
<div style={{ display: "flex", perspective: 1200 }}>
{word.split("").map((c, i) => {
const s = spring({ frame: frame - i * 4, fps, durationInFrames: 30 });
const rot = interpolate(s, [0, 1], [-180, 0]);
return <span style={{ display: "inline-block", transform: `rotateX(${rot}deg)` }}>{c}</span>;
})}
</div>perspective 一定要設在「外層」——CSS 3D 的透視點是父元素決定的。沒有 perspective 的話 rotateX 看起來只是上下壓扁。display: inline-block 是因為 inline 的 <span> 不接受 transform。
Step 5:LiquidMask — 用 clip-path 揭示
兩層字疊在一起:底層是 outline 描邊(-webkit-text-stroke),上層是實心填色,用 clip-path: inset(...) 從左到右揭開。
新增 LiquidMaskPreset:單字 "REVEAL"。底層 color:transparent + WebkitTextStroke
描邊;上層 absolute 黃色填色,clipPath: inset(0 ${(1-progress)*100}% 0 0),
progress = interpolate(frame, [4,36], [0,1], clamp)。
const progress = interpolate(frame, [4, 36], [0, 1], { extrapolateRight: "clamp" });
const inset = (1 - progress) * 100;
return (
<div style={{ position: "relative" }}>
<div style={{ color: "transparent", WebkitTextStroke: "2px #94a3b8aa" }}>REVEAL</div>
<div style={{ position: "absolute", inset: 0, color: "#facc15",
clipPath: `inset(0 ${inset}% 0 0)` }}>REVEAL</div>
</div>
);inset(top right bottom left) 的 right 從 100 降到 0 = 從右邊縮回 0%。clip-path 是 GPU 加速、不會觸發 layout,效能比 width 動畫好太多。
Step 6:SplitExplode — 字元向兩側炸開
字元從中心開始,向左右兩側 + 旋轉地飛開來,配合 spring 過衝感。
新增 SplitExplodePreset:單字 "EXPLODE",center = (len-1)/2。spring 0~1,
translateX: 0 → (i-center)*130,rotate: 0 → (i-center)*25。
opacity 用 [0, 0.2, 1] → [0, 1, 1] 避免一開始就見光。
const center = (word.length - 1) / 2;
{word.split("").map((c, i) => {
const s = spring({ frame, fps, config: { damping: 18, stiffness: 90 }, durationInFrames: 32 });
const tx = interpolate(s, [0, 1], [0, (i - center) * 130]);
const rot = interpolate(s, [0, 1], [0, (i - center) * 25]);
const opacity = interpolate(s, [0, 0.2, 1], [0, 1, 1]);
return <span style={{ transform: `translateX(${tx}px) rotate(${rot}deg)`, opacity }}>{c}</span>;
})}(i - center) 把字元 index 轉成「以中心為 0 的相對座標」——左邊負、右邊正,乘上 130(位移)跟 25(旋轉角度)就有對稱炸開的效果。
Step 7:GlitchSplit — RGB 三層 + screen 混色
紅、綠、藍三層相同的字疊在一起,每層各自隨機抖動,用 mix-blend-mode: screen 混色。重疊處變白,分離處漏出 RGB 邊。
新增 GlitchSplitPreset:單字 "GLITCH"。seed = floor(frame/3) 每 3 幀換一次抖動。
rand = mulberry32(seed*131+17)。三層 div:#ff0040 / #00ff80 / #0080ff,
每層 translate(隨機 ±15px, ±7px),mixBlendMode:"screen"。底下放 #0b1120 當底。
const rand = mulberry32(Math.floor(frame / 3) * 131 + 17);
const jx = () => (rand() - 0.5) * 30;
const jy = () => (rand() - 0.5) * 14;
const layer = (color, dx, dy) => ({
position: "absolute", inset: 0, color,
transform: `translate(${dx}px, ${dy}px)`,
mixBlendMode: "screen",
});floor(frame / 3) 讓抖動每 3 幀更新一次——每幀變化太快視覺上就糊掉,3 幀剛好像 stop motion。mix-blend-mode: screen 是「越亮越亮」的混色,紅+綠+藍對齊就是白色,這是 RGB 故障的物理基礎。
Step 8:ElasticPop — 字元 Q 彈彈出
每個字元從 scale 0 用低 damping 的 spring 彈到 scale 1,過衝感超強。
新增 ElasticPopPreset:單字 "ELASTIC"。每字元 delay = i*3,
spring config { damping:8, stiffness:140, mass:0.8 }(低 damping = 大過衝)。
scale 直接用 spring 值。奇偶紫/白交替。
const s = spring({
frame: frame - i * 3, fps,
config: { damping: 8, stiffness: 140, mass: 0.8 },
durationInFrames: 40,
});
// 直接用 s 當 scale,spring 會過衝到 ~1.15 再彈回 1damping: 8 是這個 preset 的靈魂——預設是 10,調到 8 會讓 spring 過衝到 1.15 左右再回彈。想要更狂可以調到 6,但小心字元邊緣會超出 baseline。mass: 0.8 比預設輕一點,讓彈跳更快結束。
Step 9:Wave — sin 波浪起伏
每個字元用 sin 函式做上下起伏,相鄰字元的相位差讓波浪「跑動」起來。配色用 hsl 漸層。
新增 WavePreset:單字 "WAVEFORM"。ty = sin(frame/6 + i*0.5)*30,
顏色 hsl(180 + i*18, 90%, 70%)。不需要 spring 也不需要 interpolate。
{word.split("").map((c, i) => {
const ty = Math.sin(frame / 6 + i * 0.5) * 30;
const hue = 180 + i * 18;
return <span style={{ transform: `translateY(${ty}px)`, color: `hsl(${hue},90%,70%)` }}>{c}</span>;
})}frame / 6 控制「波速」(數字越小越快),i * 0.5 是相鄰字元的「相位差」——0.5 弧度大概是 30 度,看起來剛好像 mexican wave。乘 30 是位移振幅,調大波浪會更誇張。
Step 10:Morph — Blur + Scale 交叉淡化
兩個字("SHAPE" → "MORPH")用 blur + scale 交叉淡化,視覺上像形狀融化重組。
新增 MorphPreset:兩字疊放 "SHAPE" / "MORPH"。t = interpolate(frame, [0,30], [0,1], clamp)。
SHAPE: blur 0→24, scale 1→1.4, opacity 1→0。MORPH: blur 24→0, scale 0.7→1, opacity 0→1。
const t = interpolate(frame, [0, 30], [0, 1], { extrapolateRight: "clamp" });
const lerp = (a, b) => interpolate(t, [0, 1], [a, b]);
<div style={{ position: "relative" }}>
<div style={{ filter: `blur(${lerp(0,24)}px)`, transform: `scale(${lerp(1,1.4)})`,
opacity: lerp(1,0) }}>SHAPE</div>
<div style={{ position: "absolute", inset: 0,
filter: `blur(${lerp(24,0)}px)`, transform: `scale(${lerp(0.7,1)})`,
opacity: lerp(0,1) }}>MORPH</div>
</div>關鍵是 blur + opacity 同步反向——大 blur + 低 opacity 看起來像「散開的氣態」,blur 縮回 0 + opacity 滿格時就「凝固」回字。比單純 fade 自然太多。
效能考量
10 個 preset 在 1080p 30fps 下完全可以即時 preview,但有幾個小陷阱要避開:
- 逐字
<span>數量控制:一個 preset 8~12 字元 OK,超過 30 個 React reconcile 開始拖。長句子用「整行 div +clip-path揭示」會快一個量級。 - transform 而不是 left/top:所有位移動畫都用
transform: translate(...)。left/top每幀觸發 layout 對渲染速度是災難,transform走 GPU compositing。 - filter: blur 別太貪心:blur 半徑 > 30px 在 Chromium 會明顯掉幀,Morph preset 最多用 24 是安全邊界。
- mix-blend-mode 開銷:GlitchSplit 4 層 blend preview 會掉到 24fps,但 ffmpeg 渲染不在乎,最終 mp4 還是穩 30fps。
💡 想知道更多 transform vs layout 的差異,看 /docs/transforms 跟 /docs/animating-properties。
常見問題 FAQ
Q:為什麼我的動畫在 Remotion Studio 流暢但渲染後會跳?
A:99% 是用了 Math.random() 或 Date.now() 這種非確定性的值。Remotion 渲染時是多進程平行算幀,每個 worker 拿到的隨機數不一樣,畫面就會閃。解法:所有隨機都用 mulberry32 + frame 當種子,保證每幀任何時候算出來都一樣。
Q:Scramble 每幀都亂碼一次,會不會閃爆眼睛?
A:30fps 下每幀都換確實太快。兩種解法:(1) 改成每 2~3 幀才更新一次亂碼(種子用 Math.floor(frame / 3));(2) 調慢字元解鎖速度,讓亂碼期變短。實務上 30 幀內全部 lock 完已經夠快了,觀眾的眼睛會自動模糊化。
Q:clip-path 在 mobile Safari 不支援怎麼辦?
A:你不用擔心——這是 Remotion,最終輸出是 mp4,渲染環境是 Chromium,不是 Safari。clip-path 在 Chromium 100% 支援。只有「在網頁上即時播放 Remotion Player」時才需要管 Safari 相容性,那時可以改用 SVG <mask> 或 mask-image。
Q:想做更多 preset 從哪裡找靈感? A:三個推薦來源:(1) Dribbble 搜「kinetic typography」,挑你喜歡的截圖回來拆解;(2) Motion Canvas 跟 GSAP 的官方範例庫,把它們的 timeline 翻成 Remotion 的 frame 算式;(3) After Effects 的 text animation preset 列表——大多數都能用本篇教的三個技術重現。
本篇涵蓋的官方文件
- /docs/use-current-frame — 拿當前幀
- /docs/interpolate — 插值
- /docs/spring — 物理彈簧
- /docs/sequence — 時間切片
- /docs/animating-properties — 屬性動畫總覽
- /docs/transforms — transform 與效能
下一步
- T23:粒子系統 — 用同樣的 mulberry32 + index 種子做 200 顆粒子
- T27:Glitch / VHS 故障特效 — 把 GlitchSplit 升級成全螢幕故障濾鏡
- T19:GSAP Timeline Bridge — 把 GSAP 的 timeline 翻譯成 Remotion frame
有問題歡迎到 FB 社群 討論!