設計系統建構:從 0 用 @theme 打造多品牌體系 | TailwindCSS 完整教學
前面幾篇我們把 設計 Token、元件模式、暗色模式、響應式一塊塊拆開來看;這一篇要把它們組裝成一個整體。我們會從 0 開始,用 TailwindCSS v4 的三大 CSS-first 指令——
@theme定 Token、@utility建語意 utility、@custom-variant建品牌變體——搭出一套能支援白牌與多品牌主題切換的設計系統,並談元件層、文件化與可維護性策略,讓一人專案到大型團隊都能穩健成長。
前言
設計系統(Design System) 是一套「讓產品所有介面保持一致」的完整體系。它不是一份設計稿,也不只是一包元件,而是從最小的設計決策(Token)一路向上,直到頁面樣板的分層架構,再加上一套讓所有人遵循的命名規範、文件與維護流程。簡單說:設計 Token 是原料,元件是零件,而設計系統是「把原料變成零件、再變成產品,且全程風格統一」的那條生產線與品管手冊。
先用一個生活化的類比建立心智模型。設計系統就像一間有 SOP 的中央廚房:食材規格(Token)統一採購、半成品(元件)按標準做法備料、成品(頁面)照食譜組裝。任何一家分店(專案)煮出來的味道都一致;要換菜單(換品牌)時,不用重訓每個廚師,只要換一張食材規格表,整條產線的產出就跟著變。反過來,若每個廚師各憑手感(散落的寫死樣式),分店多了口味必然走鐘。設計系統的核心價值,就是把「一致性」從個人自律變成系統保證。
本系列以 TailwindCSS v4 為預設版本。本篇你將學到:
- 分層架構:Token → utility → 元件 → 樣板 這條鏈路如何分工,以及每層的職責邊界
- 三大 CSS-first 指令:
@theme、@utility、@custom-variant各自對應設計系統的哪一層 - 多品牌 / 白牌切換:如何用 CSS 變數 override,做到「結構不變、只換值」的一鍵換膚
- 可維護性策略:元件層組織、文件化、版本控制,以及如何避免「過度工程」這個最大陷阱
本系列以 TailwindCSS v4 為預設版本,範例皆以 v4 語法示範。凡涉及 v3 差異之處,我會特別標註。
核心概念
設計系統的分層架構
一套設計系統不是一堆檔案的集合,而是一座金字塔——每一層都建立在下一層之上,職責清晰、單向依賴。理解這張圖,是理解整套系統的關鍵:
設計系統分層架構(由下而上)
──────────────────────────────────────────────
[第四層] Templates / Pages(頁面樣板)
/dashboard /settings /pricing
由「複合模式」組裝而成的完整頁面
▲ 組合
[第三層] Patterns(複合模式)
FormField(Label+Input+Error) DataTable Modal
由多個「基礎元件」組成的 UI 模式
▲ 組合
[第二層] Components(基礎元件)
Button Input Badge Card Avatar
最小的可複用 UI 單元,消費 utility
▲ 使用
[第一層] Utilities(工具類)
Tailwind 內建 + @utility 自訂語意 utility
flex bg-primary rounded-brand card-surface
▲ 源自
[第零層] Design Tokens(@theme)
--color-primary --spacing-section --radius-brand
單一真相源頭,所有上層樣式的根
──────────────────────────────────────────────
下層改動會向上擴散,上層不該反向污染下層
這張圖有兩個重點。第一,依賴是單向的:元件依賴 utility、utility 依賴 Token,反過來 Token 絕不該去引用某個元件。第二,改動的影響力由下往上放大:改一個 Token,整個系統跟著變(這是威力也是風險);改一個頁面,只影響那一頁。所以越底層的決策越要謹慎、越要集中管理——這正是上一篇《設計 Token》強調「單一真相源頭」的原因。
值得強調的是,這座金字塔不必一次蓋滿。多數專案終其一生只會用到最底下兩三層——把顏色與間距收斂成 Token、再包幾個常用元件,就已經解決了大半的一致性問題。第三層的複合模式、第四層的頁面樣板,是等專案長大、重複的組合開始出現時,才自然浮現的結晶。換句話說,分層是一種思考的框架,幫你判斷「一段樣式該住在哪一層」,而不是一份必須逐格填滿的表格。當你面對一段重複的視覺,先問它是「一個值」(進 Token)、「一組樣式」(進 utility)、還是「一塊可組裝的介面」(進元件),答案自然就把它安置到對的樓層。
三大 CSS-first 指令,對應設計系統的三個層面
v4 是 CSS-first 架構,一套設計系統的三個核心層面,剛好對應三個寫在 CSS 裡的指令,不再依賴 tailwind.config.js 與 JS plugin:
| 建構層面 | v4 指令 | 職責 | 取代的 v3 做法 |
|---|---|---|---|
| 設計 Token | @theme | 定義顏色/間距/字型,自動生成 utility 與 CSS 變數 | theme / theme.extend(JS) |
| 語意 utility | @utility | 新增系統專屬 utility,自動支援所有 variant | @layer utilities / addUtilities |
| 品牌/主題變體 | @custom-variant | 定義專屬狀態或主題 variant | addVariant(JS plugin) |
@theme是地基(第零層),負責 Token。上一篇已完整示範,本篇聚焦如何用它承載多品牌。@utility補足第一層。它比 v3 的@layer utilities強大之處在於:自訂的 utility 會自動獲得所有 variant 支援(hover:、md:、dark:),這是 v3 手寫 class 做不到的。@custom-variant讓你自訂「切換的觸發條件」,是多品牌/多主題的關鍵開關。
這三個指令背後其實是同一個哲學:把設計系統的一切都交還給 CSS。v3 時代,Token 寫在 JS config、自訂 utility 靠 @layer 手抄、自訂 variant 得寫 JS plugin——設定散在三種不同語法與檔案裡,新人要摸清楚一套設計系統得先讀懂建置管線。v4 把這三件事收斂進 CSS,你打開 globals.css 就能一眼看完「這個系統有哪些值、哪些語彙、哪些切換條件」。這種單一語言、單一位置的收斂,本身就是可維護性的一大躍進;它也讓設計系統更容易被非建置專家的設計師或後端工程師理解與參與。理解「哪個指令管哪一層」,你就掌握了在 v4 建構設計系統的完整地圖。
關鍵術語
- 語意 Token(Semantic Token):描述「角色」而非「色號」的 Token,如
--color-primary。元件只該引用它。 - 語意 utility:用
@utility把系統反覆用到的一組樣式收斂成一個具名 class,如card-surface。 - 主題 variant:用
@custom-variant定義的品牌/主題專屬前綴,如theme-ocean:。 - Token override(覆寫):同一批 Token 在不同選擇器下被重新賦值,是多品牌換膚的技術核心。
- 白牌(White-label):同一套產品外觀依客戶動態換品牌,通常 Token 值來自 runtime API。
把這幾個術語串起來看,你會發現它們其實描述同一件事的不同面向:設計系統就是「一組集中管理的值(Token)、一套讓值變成樣式的語彙(utility 與元件)、以及一組能整批切換這些值的開關(variant 與 override)」。多品牌與白牌之所以能用同一套機制優雅實作,正是因為 v4 把「值」與「使用值的地方」徹底解耦——值可以隨情境流動,而使用它的元件始終不變。抓住這條主線,後面的實作就只是把它逐步具體化而已。
實作範例
理論看完,我們從 0 搭一套支援多品牌的迷你設計系統:先用 @theme 定語意 Token,再用 @utility 建語意 utility,接著用 @custom-variant 建品牌變體,最後示範多品牌 CSS 變數切換。前提是你已有一個 v4 專案(globals.css 裡有 @import "tailwindcss";)。
步驟一:@theme 定語意 Token(地基)
先鋪好 primitive 調色盤,再讓 semantic 角色指向它。這是系統的單一真相源頭:
/* globals.css */
@import "tailwindcss";
@theme {
/* === primitive:調色盤(teal/cyan 系) === */
--color-brand-100: oklch(0.94 0.05 195);
--color-brand-500: oklch(0.68 0.15 195);
--color-brand-600: oklch(0.58 0.14 195);
--color-brand-700: oklch(0.48 0.12 195);
/* === semantic:角色(元件只引用這層) === */
--color-primary: var(--color-brand-600);
--color-primary-foreground: oklch(1 0 0);
--color-surface: oklch(0.99 0.005 195);
--color-foreground: oklch(0.15 0.01 195);
--color-border: oklch(0.9 0.01 195);
/* === 間距與圓角 === */
--spacing-section: 5rem; /* 生成 p-section、gap-section */
--radius-brand: 0.625rem; /* 10px,生成 rounded-brand */
}
步驟二:@utility 建語意 utility(第一層)
設計系統裡「一張標準卡片表面」會被反覆用到。與其每次手抄一長串 class,不如用 @utility 把它收斂成一個具名 utility——而且它會自動支援 hover:、md:、dark: 等所有 variant:
/* 語意 utility:一張標準的卡片表面 */
@utility card-surface {
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-brand);
box-shadow: 0 1px 3px oklch(0 0 0 / 0.08);
}
/* 支援巢狀選擇器的 utility(例如隱藏捲軸) */
@utility scrollbar-hidden {
&::-webkit-scrollbar {
display: none;
}
}
<!-- 一個 class 就套用整組卡片樣式,還能疊 variant -->
<div class="card-surface p-section hover:shadow-lg md:card-surface">
卡片內容
</div>
步驟三:@custom-variant 建品牌變體(切換開關)
多品牌系統常需要「只在某品牌下生效」的樣式。@custom-variant 讓你自訂一個 variant,供所有 utility 套用:
/* 定義品牌 variant:單行簡寫 */
@custom-variant theme-ocean (&:where([data-theme="ocean"] *));
/* 或完整區塊語法,搭配 @slot */
@custom-variant theme-forest {
&:where([data-theme="forest"] *) {
@slot;
}
}
<html data-theme="ocean">
<!-- 只有在 ocean 品牌下,這顆按鈕才加圓角 -->
<button class="bg-primary theme-ocean:rounded-full theme-forest:rounded-none">
依品牌變化的按鈕
</button>
</html>
小提醒:暗色模式的手動策略,其實就是
@custom-variant的特例——把dark:重新定義為 class 或 data-attribute 策略,例如@custom-variant dark (&:where(.dark, .dark *));。可見「主題切換」與「多品牌」本質是同一套機制。
步驟四:多品牌 CSS 變數切換(結構不變、只換值)
這是整套系統的高潮。因為元件一律只引用語意 Token(bg-primary、card-surface),換品牌時完全不動元件——只要針對不同品牌,用一個選擇器重新賦值同一批 Token:
/* 預設品牌已在 @theme 定義。以下針對各品牌覆寫語意 Token */
[data-theme="ocean"] {
--color-primary: oklch(0.55 0.15 240); /* 海洋藍 */
--color-surface: oklch(0.98 0.01 240);
}
[data-theme="forest"] {
--color-primary: oklch(0.52 0.15 150); /* 森林綠 */
--color-surface: oklch(0.98 0.01 150);
}
/* 暗色仍是 override,只是換一個維度 */
.dark {
--color-surface: oklch(0.16 0.01 195);
--color-foreground: oklch(0.97 0.005 195);
--color-primary: var(--color-brand-500); /* 深色下用亮一階 */
--color-border: oklch(0.3 0.01 195);
}
這裡藏著整套多品牌策略的精髓,值得停下來想清楚:元件本身對「現在是哪個品牌」一無所知。按鈕只知道自己要用 bg-primary,而 --color-primary 當下是海洋藍還是森林綠,是由 cascade(層疊)在渲染時決定的。這種關注點分離帶來三個好處:元件程式碼零改動就能支援無限多品牌;新增一個品牌只是新增一個選擇器區塊,不會動到任何現有程式碼;而測試時只要切 data-theme,就能一眼掃過每個品牌下的視覺是否成立。這也是為什麼「元件只引用語意層」不只是好習慣,而是多品牌能否成立的前提——只要有一個元件偷懶寫死 bg-blue-600,它就會在所有品牌下都固執地維持藍色,整套換膚就破了一個洞。
把根元素的 data-theme 一改,底下所有 bg-primary、card-surface 就整片換膚。若是真正的白牌——品牌設定來自後端 API,則在 runtime 用 JavaScript 動態注入 Token 值即可:
// 白牌:從 API 載入客戶品牌設定,runtime 注入 Token
async function applyBrandTheme(brandId) {
const brand = await fetch(`/api/brands/${brandId}`).then(r => r.json());
const root = document.documentElement;
root.style.setProperty("--color-primary", brand.primaryColor);
root.style.setProperty("--radius-brand", brand.borderRadius);
root.dataset.theme = brand.slug; // 同時觸發 @custom-variant 定義的品牌 variant
}
// 切換內建主題:改一個屬性,全站跟著變
function setTheme(theme) {
document.documentElement.dataset.theme = theme;
localStorage.setItem("theme", theme);
}
步驟五:元件層與目錄組織
有了 Token、utility 與 variant 這三塊地基,元件層才好蓋。一個清晰的目錄結構能讓系統可維護:
src/
├── styles/
│ ├── tokens.css ← @theme(第零層:單一真相源頭)
│ ├── utilities.css ← @utility / @custom-variant(第一層)
│ └── index.css ← 彙總入口
├── components/
│ ├── ui/ ← 基礎元件:button、input、card、badge…
│ └── patterns/ ← 複合模式:form-field、data-table、modal…
└── lib/
├── cn.ts ← clsx + tailwind-merge(合併 className)
└── variants.ts ← CVA 管理元件 variant
原則很單純:CSS 那三個指令管「系統的視覺語彙」,元件目錄管「語彙的組裝方式」,兩邊各司其職,任何視覺調整都能追溯到唯一的來源。
這裡有兩個常被忽略的細節。第一,tokens.css 應該是唯一能出現顏色 oklch(...) 這類具體值的地方;一旦在元件裡看到寫死的色值,就是設計系統漏水的訊號。第二,lib/ 裡的 cn() 與 CVA 看似瑣碎,卻是元件層可維護性的支柱:cn()(clsx + tailwind-merge)讓元件能安全地接收外部傳入的 className 並正確合併、去除衝突的 utility;CVA 則把「一個 Button 有 primary/secondary/ghost 三種樣式、sm/md/lg 三種尺寸」這類組合,收斂成一份宣告式的 variant 表,而不是散落在元件裡的一長串三元運算子。有了這兩者,元件才能既有彈性又不失一致——這正是設計系統在「規範」與「可用性」之間求取的平衡。
常見錯誤與最佳實踐
坑一:過度工程——一開始就蓋滿整套架構
這是設計系統最常見的死因。單人、一次性的小專案,硬套三層 Token + 完整元件庫 + Storybook,只會拖慢進度、增加維護面積:
/* ❌ 還沒有痛點,就先造一堆用不到的元件級 Token */
@theme {
--color-button-primary-hover-bg: oklch(...);
--color-card-header-title-color: oklch(...);
--color-modal-footer-divider: oklch(...);
}
正確做法是漸進式:先把反覆出現的顏色與間距收斂成語意 Token(投報率最高的一步),有痛點再加 @utility 與元件,最後才補文件與版本策略。讓系統跟著真實需求長出來:
/* ✅ 先只做語意層,狀態變化用 utility 衍生 */
@theme {
--color-primary: var(--color-brand-600);
--color-primary-foreground: oklch(1 0 0);
}
/* hover 用透明度衍生,不必為每個狀態造 Token */
/* <button class="bg-primary hover:bg-primary/90"> */
坑二:Token 命名不一致,系統從根爛起
命名東拼西湊,是設計系統崩壞的開端。前綴顛倒還會導致 Tailwind 不生成對應 utility:
/* ❌ 命名混亂:層次與角色都看不出來,前綴也錯 */
--primary-color: ...; /* 前綴顛倒,不會生成 utility */
--btn-bg: ...;
--MainBrand: ...;
正確做法是固定一套 [命名空間]-[角色]-[變體/狀態] 規範,而且前綴必須用 Tailwind 認得的命名空間(--color-*、--spacing-*…):
/* ✅ 統一規範,前綴正確 */
--color-primary: ...;
--color-primary-foreground: ...;
--color-primary-hover: ...;
坑三:元件繞過語意層,硬編碼寫死顏色
在元件裡寫死 bg-blue-600,等於讓這塊視覺永遠脫離系統掌控——換品牌時它不會跟著變,多品牌就破功了。只要是會隨主題/品牌變動的視覺,一律走語意 Token(bg-primary),把「決定顏色」的權力集中到 Token 這唯一的地方。
坑四:只做元件,不做文件與版本策略
沒有文件,元件庫很快就變成「只有作者看得懂」的黑盒;沒有版本策略,一次改動就可能悄悄弄壞下游。最佳實踐:
- 文件化:用 Storybook / Ladle 展示每個元件的 variant 與狀態,附使用範例。好的文件不只是「展示長相」,更要回答「什麼時候該用哪個元件、哪個 variant」,把設計決策的脈絡也寫進去。
- 版本控制:設計系統當成獨立套件,採語意化版本——破壞性變更(改 Token 名、移除元件)升 Major,新增升 Minor,修 Bug 升 Patch。這讓下游能用 lockfile 精準控制升級時機,而不是每次改動都被迫跟著動。
- 棄用流程:舊 Token 先標
@deprecated並保留,至少一個 Major 版後再移除,給下游遷移時間;能附上 codemod 腳本自動化遷移就更理想。 - 設計工具同步:用 Tokens Studio(Figma)+ Style Dictionary 把 Figma Variables 匯出成
@theme的 Token,讓設計與程式碼共用同一份真相,避免「設計稿的藍」與「程式碼的藍」悄悄分岔。
這些工程紀律聽起來很重,但別忘了坑一的教訓——它們同樣該漸進導入。一人專案不需要 codemod 與 Major 版棄用流程;等團隊變大、下游變多,痛點自然會告訴你該補哪一塊。設計系統的成熟度,應該永遠略微落後於團隊的實際規模,而不是提前把力氣花在還用不到的基礎設施上。
最佳實踐小結
- 漸進式建構:先 Token、有痛點再上 utility 與元件,別預先猜需求。
- 元件只用語意層:
bg-primary、card-surface,把總開關集中到 Token。 - 多品牌靠 override:結構不變、只換值,用
[data-theme]或 runtimesetProperty。 - 三指令各司其職:
@theme管值、@utility管語彙、@custom-variant管切換條件。 - 文件與版本並重:元件庫要能被別人安全地使用與升級,系統才活得久。
小結
這是 TailwindCSS 完整教學 系列的第三十三篇,也是設計系統主題的總結章。上一篇《設計 Token》我們把 Token 這塊原料攤開來看;這一篇,我們把 Token、utility、元件與文件組裝成一條完整的生產線。回顧幾個重點:
- 分層架構:Token → utility → 元件 → 樣板,依賴單向、下層改動向上擴散,越底層越要集中管理。
- 三大 CSS-first 指令:
@theme定 Token、@utility建語意 utility、@custom-variant建品牌變體,三者分別對應系統的值、語彙與切換條件。 - 多品牌 / 白牌切換:元件只引用語意 Token,換品牌時用
[data-theme]覆寫同一批 Token(或 runtimesetProperty注入),做到「結構不變、只換值」。 - 可維護性策略:漸進式建構避免過度工程、統一命名、拒絕硬編碼、補齊文件與版本策略。
至此,你已經有能力從 0 搭出一套能支撐一人專案到大型團隊的設計系統。地基打穩了,接下來就是把它用在最考驗一致性的場景——表單。下一篇《表單模式》就要帶你把這套系統套用到輸入框、驗證狀態與錯誤提示上,看設計系統如何讓最瑣碎的表單也能保持優雅與一致。