Remotion LabRemotion Lab
視覺特效Kinetic Typography:10 種文字動畫 Preset 一次做到會
typographyanimationinterpolatespringclip-pathintermediate

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核心技術
01Typewriterslice(0, n) + 閃爍游標
02Scramblemulberry32 + 逐字 lock
03WordStaggerspring + delay
04Rotate3DrotateX + perspective
05LiquidMaskclip-path: inset(...)
06SplitExploderadial offset + rotate
07GlitchSplit三層 + mix-blend-mode
08ElasticPopspring 低 damping
09WaveMath.sin(frame + i)
10Morphblur + 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 個,你大概就再也不需要去網路上找其他文字動畫範例了。


前置知識

整篇只會用到四個 Remotion API:useCurrentFrameinterpolatespring<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 再彈回 1

damping: 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 列表——大多數都能用本篇教的三個技術重現。


本篇涵蓋的官方文件


下一步

有問題歡迎到 FB 社群 討論!