前兩天,我們用 SCSS 處理了主題樣式,不過仍有部分畫面需要依賴圖片,像是帶有造型的按鈕、裝飾零件等。
今天想來清點這些圖片,看看哪些主題的變化,其實是能交給 CSS 變數。那麼開始吧!
先來看看目前專案的寫法。是把各個主題的圖片一一從設計稿匯出,放到資料夾中。然後在使用到的地方,匯入所有主題的靜態資源、設定對應圖片 map。再依主題名稱查詢 map,切換顯示的圖片。不能說錯,但是對於部分元件,我想或許能改用 inline SVG。
例如帶有 icon 的按鈕,還有 Call to action 按鈕。因為這類主題元件,基本圖形結構都相同,唯一的差別只在顏色變化。再加上這類設計都是向量圖形,剛好能用 inline SVG 處理。
除此之外,還有一點就是 icon、按鈕樣式容易隨著專案發展而擴充,像是播放按鈕、連結按鈕、檔案按鈕、下載按鈕等等。如果有四個主題,那每新增一種按鈕,就需要新增 4 張圖片。這還沒有算到互動樣式 hover、disabled 等,完全能預期靜態檔案數量的成長速度會很可觀。
既然如此,不如利用 Day07 學到的 inline SVG 操作方式,來管理主題 icon 與按鈕!
.vue 元件做法很單純:把設計稿匯出的 SVG 貼進 .vue 的 template,然後把寫死的色碼換成 var()。顏色就用 Day14 設定好的主題 CSS 顏色變數,以及啟用主題的 .theme-container、切換主題的 .theme-{主題名稱} class。

以暫停/播放按鈕為例,這是一個菱形按鈕,在需要動態調色的地方(icon 用 fill、邊框用 stroke)寫入 var(--theme-...),再用 props 傳入的 themeName 切換主題色。
<script setup>
defineProps({
playing: Boolean,
themeName: { type: String, default: 'coffee' },
})
</script>
<template>
<svg class="theme-container" :class="`theme-${themeName}`" viewBox="0 0 56 56" aria-hidden="true">
<rect x="9" y="9" width="38" height="38" rx="10" transform="rotate(45 28 28)" fill="#fff" stroke="var(--theme-primary-500)" stroke-width="3" />
<!-- v-if 切換圖形:true 時顯示暫停的兩條槓,false 則顯示三角形 -->
<g v-if="playing" fill="var(--theme-primary-500)">
<rect x="20.5" y="19" width="5.5" height="18" rx="1.1" />
<rect x="30" y="19" width="5.5" height="18" rx="1.1" />
</g>
<path v-else d="M24 19L37 28L24 37Z" fill="var(--theme-primary-500)" stroke="var(--theme-primary-500)" stroke-width="3" stroke-linejoin="round" />
</svg>
</template>
💡 選擇把
.theme-container放入主題元件內,是為了避免父層忘記下.theme-container導致樣式出不來。個人覺得使用主題元件時,透過修改 themeName props 的 value 來切換主題,比較直觀。

這個範例稍微複雜,按鈕有多畫四顆裝飾的星星、裁切超出外框的星星,還有內文可能改變的文字。文字部分我們用 <text> 搭配 props 接收內文,主題色的部分一樣也是在 fill 寫進主題 CSS 變數:
<script setup>
import { useId } from 'vue'
defineProps({
text: { type: String, default: '' },
themeName: { type: String, default: 'coffee' },
})
const STAR_D = 'M12 1.8L15.2 8.2L22.3 9.3L17.1 14.3L18.3 21.3L12 18L5.7 21.3L6.9 14.3L1.7 9.3L8.8 8.2Z'
// 同一頁放多個按鈕時,避免 clipPath 的 id 重複
const clipId = `theme-submit-clip-${String(useId()).replace(/[^a-zA-Z0-9_-]/g, '')}`
</script>
<template>
<svg class="theme-container shadow" :class="`theme-${themeName}`" viewBox="0 0 185 62">
<g :clip-path="`url(#${clipId})`">
<rect width="184" height="61" rx="22" fill="var(--theme-primary-500)" />
<!-- 亮點星星 -->
<path :d="STAR_D" fill="var(--theme-primary-300)" transform="..."/>
<path :d="STAR_D" fill="var(--theme-primary-300)" transform="..."/>
<!-- 暗色星星 -->
<path :d="STAR_D" fill="var(--theme-primary-600)" transform="..."/>
<path :d="STAR_D" fill="var(--theme-primary-600)" transform="..."/>
</g>
<rect x="1.5" y="1.5" width="181" height="58" rx="21" fill="none" stroke="#fff" stroke-width="3" />
<defs>
<clipPath :id="clipId">
<rect width="184" height="61" rx="22" />
</clipPath>
</defs>
<text
v-if="text"
x="92"
y="30"
fill="#fff"
...
>{{ text }}</text>
</svg>
</template>
💡 如果圖形有用到
<g clip-path="url(#id)">,要注意clipPath是用url(#id)引用,同一頁引用多次該元件時會因為 id 重複而裁切異常。
這個按鈕原本還有寫在 SVG 的投影 <filter>,視覺上是陰影,但實際會被包含在 SVG 內。如果陰影面積較大,就會把 svg 的範圍撐大。所以要把 Figma 匯出的陰影 <filter> 拿掉,改成對 <svg> 下 CSS 的 filter: drop-shadow():
.shadow {
filter: drop-shadow(2px 2px 2px rgb(0 0 0 / 0.15));
}
使用時,將主題名稱作為 props 傳入,元件就會自己顯示對應的主題色:
<button type="button" class="btn-play" @click="playing = !playing" :aria-label="playing ? '暫停' : '播放'">
<ThemePlayBtn :playing="playing" :theme-name="themeName" />
</button>
<button type="button" class="btn-submit">
<ThemeSubmitBtn text="送出答案" :theme-name="themeName" />
</button>

Codepen 連結
Codepen 一個 pen 只提供免費仔 3 個檔案,Vue 的範例都只能寫在同一個 .vue 中,寫法會變的彎彎繞繞,較難直接參考。
在研究之前專案的寫法時,得知有種寫法叫 SVG sprite。簡單來說就是將所有圖形的設定寫在同一個 <svg> 標籤的 <symbol> 中,再用 <use> 的 href="" 引用不同的圖形。聽起來非常方便。但是這樣不會讓 <svg> 檔案變超長的嗎?
稍微了解了一下,發現實務上多使用套件自動產生 SVG sprite。這邊附上文章,讓大家參考參考:
v-for 或 map() 渲染多個複雜的 SVG。