Remotion LabRemotion Lab
SVG × 3DSVG 手寫簽名:evolvePath + stroke-dasharray
svgpathshandwritingstroke-dasharray

SVG 手寫簽名:evolvePath + stroke-dasharray

用 @remotion/paths 的 evolvePath 把字母一筆一筆「簽」出來。涵蓋 stroke-dasharray 原理、multi-subpath 的陷阱,以及真正讓字母依序出現的做法。

成品預覽

5 秒、一個段落的手寫動畫示範。用 evolvePathDebugtuboshu 12 個字母一筆一筆「簽」出來,從 D 開始、依序寫下每一個字母。看起來很簡單,但這裡藏了一個很多人第一次做都會踩到的 SVG 陷阱——底下會完整講清楚。


這篇會用到

  • @remotion/paths — 官方提供的純 JS 路徑工具集,本篇會用 evolvePathgetLength 兩個 API
  • SVG <path>stroke-dasharray / stroke-dashoffset — 這是手寫動畫的底層原理

核心知識點一句話:stroke-dasharray 設成 [pathLen, pathLen]、再用 stroke-dashoffsetpathLen 漸進到 0,整條線就會從起點被「畫」出來evolvePath(progress, d) 就是把這個公式包成一行的純函式。


前置知識

如果你還不熟 Remotion 的 interpolate / useCurrentFrame / Sequence 三件套,建議先看 T3:YouTube 片頭動畫。本篇會大量使用 interpolate(frame, [a, b], [0, 1]) 這種寫法,不會再從頭解釋。


Step 1:安裝套件

npm install @remotion/paths

@remotion/paths 是純 JS 實作,不依賴瀏覽器的 SVGPathElement。這代表你可以在 Node、Web Worker、甚至 Lambda render 環境裡用它,速度也比 DOM API 快不少。


Step 2:一條連續曲線的基本寫法

最基本的手寫動畫只需要兩行核心邏輯:把一個 0~1 的進度餵給 evolvePath,它會回給你 strokeDasharraystrokeDashoffset,直接套到 <path> 上就完成了。

import {evolvePath} from '@remotion/paths';
import {interpolate, useCurrentFrame} from 'remotion';
 
// 一筆畫成的 "D"
const ONE_STROKE = "M 430 560 L 430 440 Q 510 440 510 500 Q 510 560 430 560";
 
const SignatureSection: React.FC = () => {
  const frame = useCurrentFrame();
 
  const drawProgress = interpolate(frame, [5, 80], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
  });
 
  const {strokeDasharray, strokeDashoffset} = evolvePath(
    drawProgress,
    ONE_STROKE,
  );
 
  return (
    <svg width="1920" height="1080" viewBox="0 0 1920 1080">
      <path
        d={ONE_STROKE}
        stroke="white"
        strokeWidth={8}
        strokeLinecap="round"
        strokeLinejoin="round"
        fill="none"
        strokeDasharray={strokeDasharray}
        strokeDashoffset={strokeDashoffset}
      />
    </svg>
  );
};

幾個小細節:

  1. drawProgress 從第 5 幀才開始,留一點 buffer 避免一進場就在動
  2. strokeLinecap="round"strokeLinejoin="round" 讓筆畫端點變成圓的,看起來更像真的筆
  3. fill="none" 是必要的——不然 SVG 會把整條曲線當成一個封閉區域填色,畫面就毀了

這樣寫 對一條連續曲線完美運作。但如果你想寫一個完整的單字或簽名,事情就會開始變怪了。


Step 3:multi-subpath 的陷阱——為什麼 D、e、b、u 會同時動

假設你把 12 個字母的 path 塞進同一條 d 屬性,每個字母之間用 M(moveTo,代表「提筆」)分隔:

const SIGNATURE_PATH = `
  M 430 560 L 430 440 ... 430 560   // D
  M 600 560 Q 545 570 ... 530 535    // e
  M 610 440 L 610 565               // b stem
  ...
`;

然後套上 evolvePath、跑下去——你會看到 12 個字母同時各自被畫出一半,不是 D 先寫完、e 再寫。這不是 bug,也不是 @remotion/paths 的問題,而是 SVG 規範的行為:

stroke-dasharray 對每一個 subpath(由 M 分隔)獨立套用 dash 模式。

意思是 evolvePath 算出來的 [totalLen, totalLen] dash pattern 會同時套在每個 subpath 上,所以每個字母都會獨立從 0% 畫到 100%——全部同步。

要做真正的「從 D 開始一筆一筆簽」,正確做法是:把每一筆當作獨立的 <path> 元素,自己算每一筆的「進度視窗」,讓第 N 筆等前 N-1 筆都畫完才開始動。


Step 4:正確做法——每一筆獨立 <path> + 累積長度

import {evolvePath, getLength} from '@remotion/paths';
import {AbsoluteFill, interpolate, useCurrentFrame} from 'remotion';
 
// 每一個「下筆→提筆」算一筆 — "Debugtuboshu" 共 17 筆
const SIGNATURE_STROKES: string[] = [
  // D
  "M 430 560 L 430 440 Q 510 440 510 500 Q 510 560 430 560",
  // e
  "M 600 560 Q 545 570 538 545 Q 535 515 565 510 Q 595 510 595 535 L 530 535",
  // b stem
  "M 610 440 L 610 565",
  // b bowl
  "M 610 510 Q 685 505 685 540 Q 685 575 610 565",
  // u
  "M 695 510 L 695 550 Q 730 580 770 555 L 770 510 L 770 570",
  // g bowl
  "M 855 510 Q 790 510 790 540 Q 790 570 855 565 L 855 510",
  // g descender
  "M 855 515 L 855 600 Q 855 628 800 618",
  // t stem
  "M 880 470 L 880 555 Q 915 575 950 558",
  // t cross
  "M 858 505 L 920 505",
  // u
  "M 955 510 L 955 550 Q 990 580 1030 555 L 1030 510 L 1030 570",
  // b stem
  "M 1040 440 L 1040 565",
  // b bowl
  "M 1040 510 Q 1115 505 1115 540 Q 1115 575 1040 565",
  // o
  "M 1200 540 Q 1200 505 1160 505 Q 1120 505 1120 540 Q 1120 575 1160 575 Q 1200 575 1200 540 Z",
  // s
  "M 1290 515 Q 1230 505 1225 530 Q 1225 545 1275 550 Q 1295 555 1285 568 Q 1255 580 1215 565",
  // h stem
  "M 1310 440 L 1310 565",
  // h arch
  "M 1310 518 Q 1380 505 1385 545 L 1385 565",
  // u
  "M 1395 510 L 1395 550 Q 1430 580 1470 555 L 1470 510 L 1470 570",
];
 
// 每一筆的長度 + 累積起點(module scope 只算一次,不用每幀重算)
const STROKE_LENGTHS = SIGNATURE_STROKES.map((d) => getLength(d));
const TOTAL_LENGTH = STROKE_LENGTHS.reduce((a, b) => a + b, 0);
const CUM_STARTS = STROKE_LENGTHS.reduce<number[]>((acc, _len, i) => {
  acc.push(i === 0 ? 0 : acc[i - 1] + STROKE_LENGTHS[i - 1]);
  return acc;
}, []);
 
const SignatureSection: React.FC = () => {
  const frame = useCurrentFrame();
 
  const drawProgress = interpolate(frame, [5, 80], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
  });
  const drawnLength = drawProgress * TOTAL_LENGTH;
 
  return (
    <AbsoluteFill>
      <svg width="1920" height="1080" viewBox="0 0 1920 1080">
        {SIGNATURE_STROKES.map((d, i) => {
          const startLen = CUM_STARTS[i];
          const strokeLen = STROKE_LENGTHS[i];
          // 這一筆在總長度中的 local 進度:0 = 還沒輪到,1 = 已畫完
          const localProgress = Math.max(
            0,
            Math.min(1, (drawnLength - startLen) / strokeLen),
          );
          if (localProgress <= 0) return null;
 
          const {strokeDasharray, strokeDashoffset} = evolvePath(
            localProgress,
            d,
          );
          return (
            <path
              key={i}
              d={d}
              stroke="white"
              strokeWidth={8}
              strokeLinecap="round"
              strokeLinejoin="round"
              fill="none"
              strokeDasharray={strokeDasharray}
              strokeDashoffset={strokeDashoffset}
            />
          );
        })}
      </svg>
    </AbsoluteFill>
  );
};

整個心智模型是:

  1. 把動畫的時間軸對應到「總筆長」drawnLength = progress × totalLength
  2. 每一筆問自己:「現在的筆尖通過我了嗎?」:用 startLenstartLen + strokeLen 判斷
  3. 還沒輪到的筆:return null(不要 render 一個 progress=0 的 path,肉眼看不到但 React 負擔不小)
  4. 正在畫的那一筆:用 evolvePath(localProgress, d) 算 dash pattern
  5. 已畫完的筆:localProgress 被 clamp 到 1,整筆完整顯示

每一筆是一個 <path>、每一筆有自己獨立的 dash pattern,SVG 的「subpath 獨立」特性反而變成你的朋友——剛好讓你精確控制每一筆的狀態。

為什麼 STROKE_LENGTHS / CUM_STARTS 要放在 module scope? 因為 getLength 每次呼叫都要重新跑一次 path 字串解析,塞進 module scope 就只算一次、重複使用。每幀都跑 17 次 getLength 也不會崩,但沒必要。


Step 5:用 spring 做更有彈性的進場

如果你想要更「彈性」的進場感,可以把 interpolate 換成 spring

import {spring, useVideoConfig} from 'remotion';
 
const {fps} = useVideoConfig();
const drawProgress = spring({
  frame,
  fps,
  config: {damping: 200},
  durationInFrames: 75,
});

damping: 200 表示完全沒有彈跳,純粹是一個 ease-out 曲線。如果你想要寫到一半「彈一下再停」,把 damping 降到 10~20 就會看到效果——不過簽名動畫通常不需要彈跳,反而會讓人覺得不專業。


Step 6:加上筆觸音效

文字動畫配上「沙沙」的筆觸音效,觀感會馬上提升一個等級。做法是在 <Sequence> 裡穿插幾個短音檔,讓它們跟 drawProgress 的節奏大致對齊:

<Sequence from={5} durationInFrames={12}>
  <Audio src={staticFile("audio/pen-tick.mp3")} volume={0.3} />
</Sequence>
<Sequence from={25} durationInFrames={12}>
  <Audio src={staticFile("audio/pen-tick.mp3")} volume={0.3} />
</Sequence>
<Sequence from={45} durationInFrames={12}>
  <Audio src={staticFile("audio/pen-tick.mp3")} volume={0.3} />
</Sequence>
<Sequence from={65} durationInFrames={12}>
  <Audio src={staticFile("audio/pen-tick.mp3")} volume={0.3} />
</Sequence>

不用精確對到每一筆,分佈在整段 draw 時間內就好。人耳對「大致的節奏感」很寬容,但對「完全沒聲音」很敏感。


Step 7:渲染

npx remotion render TutorialSvgSignatureDemo out/signature.mp4 --codec=h264

原理解析:為什麼 stroke-dasharray 能做手寫

這是 SVG 動畫圈最經典的小聰明,值得花點時間講清楚:

  • SVG 的 stroke-dasharray 定義「實線段-空白段-實線段-空白段」的循環。例如 stroke-dasharray="20 10" 就是「實線 20、空白 10、實線 20、空白 10...」一直重複到 path 結束
  • stroke-dashoffset 控制 dash 從哪裡「開始」。設成 5,整條 dash pattern 就會往後位移 5 個單位
  • 手寫動畫的關鍵:把 stroke-dasharray 設成 [totalLength, totalLength]——也就是「實線 = 整條 path 長度」、「空白 = 整條 path 長度」
  • 然後 stroke-dashoffsettotalLength 慢慢降到 0
  • 一開始 offset = totalLength,整條實線段被「推出去」path 範圍外,所以你看不到任何線
  • 隨著 offset 往 0 移動,實線段慢慢「滑進來」,從起點開始一吋一吋顯現
  • 視覺效果就是「筆」在寫字

evolvePath(progress, path) 就是把這個算式包成一個純函式:你給它一個 0~1 的進度,它幫你算好 strokeDasharraystrokeDashoffset 兩個值。你不需要記公式,也不需要每次都呼叫 getLength,它都包好了。


常見問題

Q: 手寫動畫太快/太慢?

兩種調法:

  • 如果用 spring:改 durationInFrames(彈簧的「目標時間」)
  • 如果用 interpolate:改 [start, end] 的範圍,例如範例用 [5, 80](~2.5 秒簽完),想要慢一倍就改成 [5, 155]

進一步控制每段「快慢節奏」可以串多段 interpolate,例如「前 30% 慢 → 中間 40% 快 → 最後 30% 慢」這種「先穩、後爆、再收」的節奏。注意用 Step 4 的多筆寫法時,調的是總進度,每一筆會按照自己佔總筆長的比例自動分配時間——想讓某個字母被寫得特別慢,可以把那一筆拆成更多段。

Q: 為什麼我用 <text> 的 stroke-dasharray 沒效果?

SVG <text> 元素的 stroke 渲染是字形外框(glyph outline),pathLength / stroke-dasharray 行為跟 <path> 不完全一樣,而且每個字母是獨立的 glyph(等於 multi-subpath),一樣會踩到 Step 3 的同步陷阱。想做文字手寫動畫就乖乖把字母轉成 <path>——Figma / Illustrator 都有「convert text to outlines」功能。

Q: 我想要寫完之後筆還在尾端停留?

localProgress 算出來之後、對應到那一筆的末端座標,疊一個小圓點 <circle>,位置用 getPointAtLength(d, strokeLen) 取得。看下一篇 SVG 沿路徑移動物件 會完整講 getPointAtLength 的用法。


本篇涵蓋的官方文件


下一步

SVG 沿路徑移動物件:飛行航線 + 自動轉向 — 手寫動畫是「把 path 畫出來」,下一篇要反過來:path 已經存在,讓一個物件沿著它走。核心 API 是 getPointAtLength + getTangentAtLength,用來讓飛機沿著 Taipei → Tokyo 的航線飛、而且自動順著彎道轉向。