面對重複出現卻略微不同的動態效果,Day23 把「差異」抽成了參數。今天我們換個角度想:如果動畫本來就是這張圖的一部分,不如讓它「跟著圖」走?
之前看專案時發現一個狀況:同一批裝飾用的 SVG 被兩個畫面引用,兩邊都是用 <img> 載入,所以搖晃、漂浮的動畫各自寫在各畫面的 CSS 裡。兩邊都有一整套同名的 @keyframes,內容超過九成一樣,只差在定位點與圖片尺寸。而且 CSS 裡還將「版面」與「動作」混在一起,例如:top 與 left 同時負責了圖片的定位與動畫的位移。
這代表把圖搬去別的地方,動畫就要再抄一次。剛好研究上一小題目 SVG 的時候,發現有供動畫使用的標籤。今天就來看看該如何讓 SVG 本身動起來。那麼我們開始吧!
先來做基礎介紹,前面有提到 SVG 內建一組動畫標籤(統稱 SMIL),能直接寫在要動的元素裡面當子標籤,不需要 CSS,也不需要 JS。最常用的有三個。
💡 以下範例都是抽出動畫的部分,完整結構可以參考 Codepen 範例。
<animate>:讓「屬性」隨時間變化通用性最高的標籤。用 attributeName 指定屬性,再給 from(初始值) 與 to(終點值),或是改變路徑的 values,最後加上 dur(動畫持續時間)就會動。
<circle cx="50" cy="60" r="0" fill="#8fbf7f">
<animate attributeName="r" from="0" to="18" dur="0.6s" fill="freeze"/>
</circle>
<!-- 路徑變形的寫法:在 `values` 寫好幾組 `d` -->
<path d="M50 10 C70 40 80 60 50 100 C20 60 30 40 50 10 Z" fill="#e8743b">
<animate attributeName="d" dur="1.2s" repeatCount="indefinite"
values="M50 10 C70 40 80 60 50 100 C20 60 30 40 50 10 Z;
M50 24 C76 46 72 68 50 100 C28 68 24 46 50 24 Z;
M50 10 C70 40 80 60 50 100 C20 60 30 40 50 10 Z"/>
</path>

它能動的是 SVG 的屬性,像是 r、cx、x、opacity、fill、路徑的 d,甚至濾鏡的 baseFrequency。CSS 能改的多半是元素整體的變換(位移、旋轉、縮放),要改形狀本身或濾鏡參數就比較難。
💡 路徑變形有個限制:每組
d的路徑指令「數量、種類、順序」要一致(上面都是M C C Z),才能平滑補間。SVG 2 規格(§9.3.2 Thedproperty)寫的是,結構不同時改用離散動畫,也就是沒有中間畫面,直接跳換。
<animateTransform>:專門處理 transformtransform 的值是函式(例如 rotate(…)),不能像數字一樣直接補間,所以另外有個專門的標籤,用 type 指定是哪一種變形:translate、scale、rotate、skewX、skewY。attributeName 固定寫 transform。
範例 - 吊燈以頂端為軸心左右擺動:
<g>
<animateTransform attributeName="transform" type="rotate"
values="-6 50 0; 6 50 0; -6 50 0"
keyTimes="0; 0.5; 1"
calcMode="spline" keySplines=".45 .05 .55 .95; .45 .05 .55 .95"
dur="3s" repeatCount="indefinite"/>
<line x1="50" y1="0" x2="50" y2="42" stroke="#6b5b45" stroke-width="3"/>
<path d="M28 76 Q50 28 72 76 Z" fill="#f2b134"/>
</g>

rotate 的值可以帶旋轉中心,格式是 "角度 cx cy"。上面的 -6 50 0 就是「繞著 (50, 0) 轉 -6 度」,中心點是直接寫在值裡(數值是指 SVG 座標)。
<animateMotion>:讓元素「沿著路徑」移動有兩種作法,用 path 直接寫路徑,或是放一個 <mpath> 引用現成的 <path>。咖啡杯的蒸氣就是三顆氣泡沿著同一條彎曲路徑往上飄:
<path id="steamPath" d="M50 92 C28 76 72 58 50 40 S40 14 52 4" fill="none"/>
<circle r="5" fill="#9aa7b4">
<animateMotion dur="3s" repeatCount="indefinite">
<mpath href="#steamPath"/>
</animateMotion>
<animate attributeName="opacity" values="0;.8;0" dur="3s" repeatCount="indefinite"/>
</circle>
加上 rotate="auto",元素還會隨著路徑的方向轉向,適合箭頭、小飛機這類有方向性的東西。

CSS 也有同樣的功能,叫 offset-path 搭配 offset-distance,做的是同一件事。不過它比較新,Safari 要 18 以上才支援,專案如果還要顧到舊版 iOS,就不能依賴它。
以上標籤共用同一套時間相關的屬性,剛好跟 CSS 動畫可以一一對照:
| 設定 | 作用 | 對應 CSS |
|---|---|---|
dur |
單次時長 | animation-duration |
repeatCount="indefinite" |
重複次數 | animation-iteration-count: infinite |
begin |
開始時間;可以接別的動畫結束 | animation-delay(串接要自己算) |
fill="freeze" |
結束後停在終點 | animation-fill-mode: forwards |
values + keyTimes |
多個關鍵值與各自的時間點 | @keyframes 的百分比 |
calcMode="spline" + keySplines |
每段之間的緩動 | animation-timing-function: cubic-bezier() |
瀏覽器支援度方面,SMIL 很早就有了:Chrome 5、Safari 6、Firefox 4 起支援,在 Can I use 上標示為 Baseline「Widely available」,全球使用率約 97%。
如果不想要用 SMIL,其實,也可以在 SVG 內放 <style>,直接在內部用 @keyframes、animation 等CSS 屬性。
<svg viewBox="0 0 100 100">
<style>
.sw {
transform-origin: 50px 0;
animation: sw 3s ease-in-out infinite alternate;
}
@keyframes sw { 0% { transform: rotate(-6deg); } 100% { transform: rotate(6deg); } }
</style>
<g class="sw">…吊燈…</g>
</svg>
對於熟悉 CSS 的人,這樣寫比較容易理解,好像不必特別去用 SMIL。先別急,我們來看這兩種寫法各自的特色與擅長的地方。
SMIL 能用 begin="{動畫標籤 id}.end" 把兩段動畫標籤串起來,意思是「等指定 id 動畫那段結束,就開始這段」,像是這個例子中,圓球先進場從 0 長到定位,接著上下循環漂浮:
<circle cx="50" cy="60" r="0" fill="#8fbf7f">
<animate id="intro" attributeName="r" from="0" to="18" dur="0.6s" fill="freeze"/>
<animate attributeName="cy" values="60;48;60" dur="2s"
begin="intro.end" repeatCount="indefinite"/>
</circle>
對比之下,CSS 的做法是把兩個動畫並列,第二個用 animation-delay 手動等:
circle {
animation:
pop 0.6s ease-out,
bob 2s ease-in-out 0.6s infinite; /* 0.6s 要自己對齊進場時長 */
}
差別在於,SMIL 是事件關係,進場時長改成 1 秒,循環會自動跟著延後;CSS 是時間累計,改了進場時長,後面的 0.6s 得記得一起改,忘了就會重疊或出現空檔。
💡 注意
begin="intro.end"裡的 id 不要用連字號。之前把 id 取成intro-f5,進場正常,但後面的漂浮卻一直沒有開始,改成introf5就正常了。
動畫寫進 SVG 之後,還有一個要注意的點,那就是這張 SVG 是怎麼被載入的。不同的載入方式,外面能做的事差很多。
範例裡用同一盞吊燈,做了三種組合:
| 組合 | 外部能控制什麼 |
|---|---|
SMIL + inline <svg> |
可以用 SVG 元素的方法控制 SMIL 時間軸,例如 pauseAnimations()、unpauseAnimations()、setCurrentTime(0) |
SVG 內 CSS + inline <svg> |
animation-play-state 可暫停;CSS 變數(例如 --swing)可以從外面調角度 |
SVG 內 CSS + <img src> |
沒有,變數與 JS 都碰不到 <img> 內部 |

從 Codepen 範例 中可以看到:
pauseAnimations() 等方法只管 SMIL,對 CSS 動畫沒有作用。<img> 載入的 SVG 是一個獨立小文件,只能以 SVG 裡寫死的預設值播放,外面無法暫停,也沒辦法透過 CSS 變數去調整它。另外要小心一件事,inline 時,SVG 裡的 <style> 是整份文件共用的,選擇器與 @keyframes 名稱都沒有隔離。CSS 設定容易互相覆蓋。所以需要幫選擇器和 keyframes 名稱加上獨一無二的前綴(<img> 載入就沒有這個問題)。
回到今天開頭的目標,想要讓動畫留在圖片內。當使用到它的時候,只需要決定外層的事情,也就是 SVG 圖片有多大、要定位在哪裡?
全部寫在圖片使用的地方也不是不行,但被使用的地方一多,代表需要重複多份 CSS 設定。或者要將設定拉出來放到全域,可是對於只是特定圖片使用到的動畫效果,這樣做又有些殺雞焉用牛刀。再加上,為了下 CSS 的 class,可能需要把一張 SVG 拆成好多張零件,例如一條藤蔓上有多片會晃動的葉片,讓檔案管理更加複雜。
把動畫寫在 SVG 內,可以很好地將只屬於圖片的設定封裝起來,不會與外部互相干擾。SVG 的事,就留在 SVG 上。
以下狀況會考慮使用 SMIL:
<animate> 剛剛好。可能不適合的狀況:
💡 Day23:CSS 變數解決的是「差異」,SVG 動畫解決的是「動畫跟誰走」。
.
.
.
前幾天都用 CSS 寫動畫,今天則介紹了 SVG 的動畫標籤:<animate> 改屬性、<animateTransform> 處理 transform、<animateMotion> 沿路徑移動;也看到載入方式會決定外面能不能控制。原本以為「讓動畫跟著圖走」只是換個地方寫,實際做下去才發現,重點應該在於為什麼要這樣拆分,有哪些好處與限制。
Day24 完成,剩 6 天! ズイ₍₍(ง˘ω˘)ว⁾⁾ズイ
<animate> | MDN
<animateTransform> | MDN