iT邦幫忙

2026 iThome 鐵人賽

DAY 23
0
Modern Web

從實務需求出發:前端視覺與 UI 互動開發實踐系列 第 23 篇

【Day 23】動畫進階:@property 協助管理、WAAPI 動態設定

  • 分享至 

  • xImage
  •  

動畫寫得很爽,一不小心維護火葬場。今天想換個角度:動畫寫出來之後,要怎麼維護?

曾經遇過這種情況:同一隻吉祥物可以切換多個主題與表情。每個主題各自一份元件,動畫也就各自複製一份。久了之後會發現,這些動畫其實「長得一模一樣」,只差在幾個數字。今天就來看看怎麼把這些差異抽成參數,再看看有哪些事情是 CSS 做不到、需要交給 JavaScript 的。

今天的助教是一隻簡易小熊,他的手與腳兩個部位會動,並且手上拿著各主題的專屬物品。那我們開始吧!

範例角色截圖


一、把差異抽成 CSS 變數

先來看原始程式碼,三個主題(coffee、matcha、berry)都會揮手,結構完全一樣:舉著、停一下、放下、再舉起。差別只有角度與週期:

@keyframes waveCoffee { 
  0%,40% { transform: rotate(10deg); }
  70%,75% { transform: rotate(0deg);  }
  100% { transform: rotate(10deg); } }
@keyframes waveMatcha {
  0%,40% { transform: rotate(40deg); }
  70%,75% { transform: rotate(0deg);  }
  100% { transform: rotate(40deg); } }
@keyframes waveBerry  {
  0%,40% { transform: rotate(40deg); }
  70%,75% { transform: rotate(10deg); }
  100% { transform: rotate(40deg); } }
.coffee .hand { animation: waveCoffee 2.2s ease-in-out infinite; }
.berry  .hand { animation: waveBerry  3s   ease-in-out infinite; }

這會帶來幾個麻煩:

  • 差異只是「兩個數字」,卻複製了整份 keyframes。
  • 如果要讓角色生氣時揮得更快,得在 .is-angry 整段重寫 animation 設定。
  • 同名的 keyframes 散在各個元件裡,要追蹤很麻煩。

所以我們需要把這些數字變成參數。

keyframes 裡直接用 var()

CSS 變數可以直接寫在 @keyframes 裡。共用一份 keyframes,角度與週期都改成讀變數:

.hand {
  animation: wave var(--wave-duration) ease-in-out infinite;
}

@keyframes wave {
  0%, 40%   { transform: rotate(var(--wave-up)); }
  70%, 75%  { transform: rotate(var(--wave-down)); }
  100%      { transform: rotate(var(--wave-up)); }
}

各主題只要宣告變數:

.matcha { --wave-up: 40deg; }
.berry  { --wave-up: 40deg; --wave-down: 10deg; --wave-duration: 3s; }

生氣的狀態也變得很單純,只改一個變數,不用重寫 animation:

.is-angry { --wave-duration: 0.5s; }

只用 var() 的問題

想要模組化動畫設定其實單用 CSS 變數就行了。勝ったッ!Day23,完!

且慢,雖然範例寫得很簡單,但角色動畫往往可動處多,參數設定容易變得很龐雜,如果全抽變數的話,可能會遇到以下問題:

  • 沒有型別:--wave-up 寫成 50(忘了單位),瀏覽器不知道它該是角度,也不會報錯。
  • 預設值散落各處:為了避免變數沒設定,到處寫 var(--wave-up, 10deg),改預設值要改很多處,也容易不一致。
  • 預設會繼承:父層主題設定的值,可能漏進不該被影響的子元素。

這時候就輪到 @property 登場。

用 @property 補上型別

@property 可以「註冊」一個自訂屬性,告訴瀏覽器它的型別、預設值,以及要不要繼承:

@property --wave-up       { syntax: '<angle>'; inherits: true; initial-value: 10deg; }
@property --wave-down     { syntax: '<angle>'; inherits: true; initial-value: 0deg; }
@property --wave-duration { syntax: '<time>';  inherits: true; initial-value: 2.2s; }

三個描述子的作用:

描述子 作用
syntax 這個變數接受什麼型別,例如 <angle>、<time>、<length>、<number>
initial-value 預設值。有了它,就不必再寫 var(--x, 10deg) 的 fallback
inherits 要不要從父層繼承

註冊之後,原本的 var(--wave-up, 10deg) 就可以直接寫成 var(--wave-up),預設值只在註冊的地方寫一次。

瀏覽器支援度方面,@property 從 Chrome 85、Edge 85、Safari 16.4、Firefox 128 起支援。相對普及,但還是要小心專案是否要支援較舊版本的瀏覽器,尤其是 Firefox。

有了型別,變數才可做過渡

變數有了型別,瀏覽器就知道它是什麼單位,以範例為例就是知道 --wave-up 是指「deg 角度」,這時候可以直接對它做 transition:

.char {
  transition: --wave-up 0.8s ease;
}

以下在範例有兩隻小熊對照:一隻有註冊 --wave-up,一隻用沒註冊的一般變數。把舉手的角度從 10deg 改成 50deg:

範例 gif

有 @property 沒有 @property
改成 50deg 約 0.8 秒內平滑變化 立刻變成 50deg,沒有過渡
寫錯單位 50 值被丟棄,退回預設的 10deg,手繼續揮 rotate(50) 無效,整個 transform 失效,手完全不動,而且沒有任何錯誤提示

沒有註冊的一般變數,對瀏覽器來說只是一串文字,它不知道該怎麼在兩個值之間補間,所以只能瞬間切換。而寫錯單位兩者的差別更明顯。有型別時,錯誤的值會被擋下來、退回預設,畫面不會壞;沒有型別時,整個動畫就默默失效了。

💡 @property 補的是「自訂變數」的型別。transform: rotate() 這類內建屬性,瀏覽器本來就知道型別,能自己補出中間值,所以不需要 @property。只有想讓「變數本身」被過渡、或想要型別檢查時,才需要註冊它。


二、用 JS 控制:Web Animations API

變數解決了不同情況下該如何動,但有些事情純 CSS 還是很難做到,像是:

  • 動畫播到一半,改變速度。
  • 將動畫暫停、繼續、倒轉。
  • 一個動畫播完之後,再接下一個動作。

以上這些是在動畫播放時做出的控制行為,這時可以交給 JavaScript 的 Web Animations API(WAAPI)。

如何寫 el.animate()?

el.animate(keyframes, options) 的設定跟 CSS 動畫幾乎能一一對應:

CSS WAAPI
@keyframes keyframes:物件陣列,用 offset(0~1)代替百分比
animation-duration: 2.2s duration: 2200(單位是毫秒)
animation-timing-function easing
animation-iteration-count: infinite iterations: Infinity
animation-delay delay

它會回傳一個 Animation 物件,之後就靠這個物件來控制動畫。

💡 el 是用 document.querySelector() 之類取得的 DOM 元素。animate() 則是 WAAPI 的入口,呼叫之後會建立一個動畫,並回傳一個 Animation 物件。

將前面的 CSS 設定改用 JS,CSS 變數就要轉換成函式的參數。把以揮手為例,將動畫設定包成一個函式:

export function waveHold(el, { up = 10, down = 0, duration = 2200, iterations = Infinity } = {}) {
  return el.animate([
    { transform: `rotate(${up}deg)`,   offset: 0 },
    { transform: `rotate(${up}deg)`,   offset: 0.4 },
    { transform: `rotate(${down}deg)`, offset: 0.7 },
    { transform: `rotate(${down}deg)`, offset: 0.75 },
    { transform: `rotate(${up}deg)`,   offset: 1 },
  ], { duration, easing: 'ease-in-out', iterations })
}

各主題只要傳參數:

waveHold(coffeeHand)   // 全部預設
waveHold(berryHand, { up: 40, down: 10, duration: 3000 })

執行中控制

拿到 Animation 物件之後,就能進一步操作動畫:

const anim = waveHold(hand)

anim.pause()                // 暫停
anim.play()                 // 繼續
anim.reverse()              // 倒轉
anim.updatePlaybackRate(2)  // 變成兩倍速

另外,還有 finished,它是一個 Promise,動畫播完才會 resolve,可以拿來串接動作。例如「答對之後揮手一次,再回到待機動畫」:

async function celebrate(hand, idle) {
  idle.pause()                                              // 待機動畫先暫停
  const once = waveHold(hand, { up: 30, duration: 1200, iterations: 1 })
  await once.finished                                       // 等揮完
  once.cancel()                                             // 清掉這次的動畫
  idle.play()                                               // 回到待機
}

範例 gif

想用純 CSS 做這件事,得靠 animationend 事件再手動切換 class,程式會比較分散,除非是要在動畫執行期間控制,不然一般循環播放的動畫交由 CSS 製作即可

💡 JS 建立的動畫不會被 CSS 的 prefers-reduced-motion 關閉,需要自己判斷:

const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches
if (!reduceMotion) startAnimations()

小補充

  1. 前端框架中封裝:在 Vue 裡,可以封裝成 composable,並且在元件卸載時清理動畫:
import { onMounted, onBeforeUnmount } from 'vue'
import { waveHold } from './characterMotion'

export function useCharacterMotion(handRef, options) {
  let anim

  onMounted(() => {
    anim = waveHold(handRef.value, options)
  })

  onBeforeUnmount(() => {
    anim?.cancel()   // 不清理的話,元件消失後動畫物件還在
  })

  return {
    setSpeed: (rate) => anim?.updatePlaybackRate(rate),
  }
}
  1. getAnimations() 拿到 CSS 動畫:WAAPI 的控制能力,也可以用在「已經存在的 CSS 動畫」上。el.getAnimations() 會回傳這個元素上所有正在跑的動畫,包含 CSS 寫的:
const cssAnim = hand.getAnimations().find(a => a.animationName === 'wave')

// 暫停對應的 CSS 動畫後,log 出他已經跑了幾毫秒
cssAnim.pause()
console.log(cssAnim.currentTime)

完整範例

CodePen 連結

今天利用 CSS 變數、@property 來管理動畫的參數,想依情況微調動畫細節,只需要調整對應變數即可。若想進一步操作播放中的動畫,則能透過 Web Animations API 從 DOM 取得 Animation 物件,用內部方法影響執行中的動畫。

之前看專案的動畫效果有很多重複卻又分散設定,剛好藉這次參加鐵人賽的機會,研究看看有什麼比較好的處理方式。現在又學到一招了,蒸蚌!


參考資料


上一篇
【Day 22】動畫實作:用 perspective 製作 CSS 3D 翻牌效果
下一篇
【Day 24】動畫進階:讓動畫跟著圖走,SVG 動畫
系列文
從實務需求出發:前端視覺與 UI 互動開發實踐 共 24 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言