設計 Token:用 @theme 打造單一真相源頭 | TailwindCSS 完整教學

2026/09/01
設計 Token:用 @theme 打造單一真相源頭 | TailwindCSS 完整教學

上一篇《shadcn/ui》我們看到它換膚的祕密,就在那組語意化的 CSS 變數——--primary--background--radius… 這些其實有個更宏大的名字:設計 Token(Design Tokens)。這一篇要帶你系統性地理解:如何在 TailwindCSS v4@theme 把 Token 變成「同時是 CSS 變數、又是 utility class」的單一真相源頭,並用 primitive / semantic 分層、--alpha() 函式與任意值引用,讓品牌、明暗、多主題都能一鍵切換。

前言

設計 Token(Design Tokens) 是設計系統裡最小、最基礎的建構單位。簡單說,它就是「把一個設計決策存成一個具名的值」——像是「主色是這個藍」、「區塊間距是 80px」、「卡片圓角是 10px」,每一個這樣的決策,都被抽出來、取一個名字、集中放在一個地方管理。這樣一來,整個產品的視覺就不再散落在成百上千個寫死的數值裡,而是收斂到一小組可被引用的 Token。

先用一個生活化的類比建立心智模型。設計 Token 就像一份公司統一的「用字典/色卡本」:設計師、前端、iOS、Android 工程師手上都拿著同一本色卡,上面寫著「Brand Green = 這個色號」。當老闆哪天說「品牌色要改」,你不需要跑遍每個房間去改每一面牆,只要改色卡本裡那一格,所有引用它的地方就自動跟著變。反過來說,如果每個人各自憑印象調色(寫死的 #3B82F6 散落各處),那換色就是一場災難。設計 Token 的核心價值,就是把「同一個設計決策」集中到「單一真相源頭(single source of truth)」

本系列以 TailwindCSS v4 為預設版本。本篇你將學到:

  • Token 分層:primitive(原始值)與 semantic(語義值)兩層如何分工,以及為什麼別過度抽象
  • @theme 的雙重身分:v4 如何讓一個 Token 同時是 --var(CSS 變數)又是 utility class
  • 命名空間(namespace):--color-*--spacing-*--font-* 等前綴如何決定生成哪類 utility
  • v4 專屬語法:--alpha()--spacing() 函式,以及任意值 [...] 與簡寫 (--var) 的引用方式
  • 與設計工具對接:從 Figma Variables 到 @theme 的工作流思路

本系列以 TailwindCSS v4 為預設版本,範例皆以 v4 語法示範。凡涉及 v3 差異之處,我會特別標註。

核心概念

設計 Token 的分層:primitive 與 semantic

一套成熟的 Token 系統不會把所有值攤平在同一層,而是分層——最常見的是兩到三層。理解分層,是理解整套設計系統的關鍵。

  • 第一層:原始 Token(Primitive / Global)。這是「具體的、無語義的」值,例如 --color-blue-600--spacing-4。它們只描述「是什麼」(這是第 600 號的藍),不描述「用來做什麼」。這一層是你的調色盤與尺標,提供所有可能被選用的原料。
  • 第二層:語義 Token(Semantic)。這一層給原始值取一個有意義的別名,例如 --color-primary: var(--color-blue-600)--color-destructive: var(--color-red-600)。它描述的是「角色」——「主色」「危險色」「頁面背景」——而不是「哪個色號」。元件應該引用這一層(用 bg-primary 而非 bg-blue-600),因為角色是穩定的,而背後綁哪個原始色可以隨時抽換。
  • 第三層(選用):元件 Token(Component)。更細的 --color-button-bg: var(--color-primary) 這類。多數專案不需要做到這層,過度細化反而是負擔(後面「常見錯誤」會談)。

分層的威力在於換膚只動語義層的指向:當你想把主色從藍換成青,你不必碰任何元件,只要讓 --color-primary 改指向 --color-cyan-600 即可。原始層是原料、語義層是決策、元件層(若有)是消費——這條「primitive → semantic → 使用」的鏈路,就是設計系統的骨架。

Token 分層架構(三層模型)
──────────────────────────────────────────────
[第一層:原始值 Primitive]  「是什麼」
  --color-blue-600   --color-red-600   --spacing-4
        │
        ▼ 引用 var()
[第二層:語義值 Semantic]   「什麼角色」
  --color-primary: var(--color-blue-600)
  --color-destructive: var(--color-red-600)
        │
        ▼ 生成 utility / 被引用
[使用端]
  bg-primary  →  background-color: var(--color-primary)
──────────────────────────────────────────────
換膚只需改「語義層」的指向,元件完全不用動

@theme 的雙重身分:既是 CSS 變數、又是 utility

v4 最核心的轉變,是把設定從 v3 的 JavaScript config(tailwind.config.js)搬進 CSS,並用 @theme 指令定義 Token。而 @theme 裡的每個 Token 都有雙重身分:

  1. 它是一個 CSS 變數:會原封不動輸出到 :root,例如 --color-brand-500,你能在原生 CSS、行內 style、JavaScript 裡用 var(--color-brand-500) 取用。
  2. 它同時生成 utility class:Tailwind 依它的命名空間自動產生對應的 utility 與 variant,例如 bg-brand-500text-brand-500border-brand-500

換句話說,你只寫一次 Token,就同時得到「給 HTML class 用的 utility」和「給 CSS/JS 用的變數」兩種消費方式,兩邊讀的是同一份值,永不失同步。這就是 v4 「CSS-first」哲學的精髓。下面這張表把 v3 與 v4 的差異、以及命名空間如何決定生成哪類 utility 講清楚:

面向TailwindCSS v3TailwindCSS v4(@theme)
設定位置tailwind.config.js(JS)globals.css 裡的 @theme { }(CSS)
Token 形式JS 物件的鍵值CSS custom property(--color-* 等)
能否直接當變數用否(要另外 expose),自動輸出到 :root
--color-primary生成 bg-primary生成 bg-primary var(--color-primary)
取值函式theme('colors.blue.500')直接 var(--color-blue-500)
帶透明度取值theme('colors.blue.500 / 50%')--alpha(var(--color-blue-500) / 50%)

命名空間(namespace)決定生成哪類 utility

@theme 不是隨便取名都會生效——Token 的前綴命名空間決定了 Tailwind 幫你生成哪一類 utility。這是必須記住的對應關係:

  • --color-* → 顏色類:bg-*text-*border-*fill-*ring-*
  • --spacing-* → 間距/尺寸類:p-*m-*gap-*w-*h-*
  • --font-* → 字型家族:font-*
  • --text-* → 字級:text-*(可附 --line-height 等子屬性)
  • --radius-* → 圓角:rounded-*
  • --shadow-* → 陰影:shadow-*
  • --breakpoint-* → 響應式斷點:sm:md:lg: variant
  • --animate-* → 動畫:animate-*

所以當你寫 --color-brand: oklch(...),你就自動有了 bg-brandtext-brand;寫 --spacing-section: 5rem,就有了 p-sectiongap-section命名空間是 Token 與 utility 之間的橋樑——記錯前綴(例如把顏色寫成 --brand-color-*),Tailwind 就不會生成你期待的 class。

@theme 的三種模式與關鍵術語

@theme 有三種寫法,各有用途:

  • @theme { }:擴展預設主題,保留 Tailwind 內建的所有值,只新增你的 Token。最常用。
  • @theme default { }:定義可被後續 @theme 覆蓋的預設值,適合函式庫作者提供可被使用者改寫的基礎。
  • @theme inline { }:讓 Token 的值直接內嵌進生成的 utility,而非透過 var() 間接引用。當 Token 的值來自外部變數、且要在切換主題時自動跟隨時(避免 CSS cascade 的解析時機問題),必須用它。

其他關鍵術語:

  • primitive / semantic Token:原始值層與語義角色層,前者是原料、後者是決策。
  • 命名空間(namespace):--color-* 等前綴,決定生成哪類 utility。
  • --alpha():v4 調整顏色透明度的函式,編譯為 color-mix()
  • --spacing():v4 依 --spacing 基準單位換算間距的函式,取代 v3 的 theme('spacing.n')
  • 任意值 [...] 與簡寫 (--var):在 utility 中引用任意值或 CSS 變數的兩種寫法。

實作範例

理論看完,我們建一套可執行的 Token 系統:先定 primitive 品牌色,再疊 semantic 角色色,接著示範 --alpha()--spacing() 與任意值引用。前提是你已有一個 v4 專案(globals.css 裡有 @import "tailwindcss";)。

步驟一:定義 primitive 與 semantic 兩層

@theme 裡,先鋪好原始色階(primitive),再用 var() 讓語義色(semantic)指向它。這樣換膚時只需改語義層那一行:

/* globals.css */
@import "tailwindcss";

@theme {
  /* === 第一層:原始 Token(Primitive)——調色盤 === */
  --color-brand-50: oklch(0.97 0.03 180);
  --color-brand-100: oklch(0.94 0.06 180);
  --color-brand-500: oklch(0.68 0.15 195);   /* teal/cyan 系主色號 */
  --color-brand-600: oklch(0.58 0.14 195);
  --color-brand-700: oklch(0.48 0.12 195);

  /* === 第二層:語義 Token(Semantic)——角色 === */
  --color-primary: var(--color-brand-600);            /* 主色角色 */
  --color-primary-foreground: oklch(1 0 0);           /* 主色上的文字 */
  --color-destructive: oklch(0.55 0.22 25);           /* 危險/刪除 */
  --color-background: oklch(1 0 0);                    /* 頁面背景 */
  --color-foreground: oklch(0.15 0.01 195);           /* 主要文字 */
  --color-border: oklch(0.9 0.01 195);                /* 邊框 */

  /* === 間距與圓角 Token === */
  --spacing-section: 5rem;    /* 生成 p-section、gap-section 等 */
  --radius-brand: 0.625rem;   /* 10px,生成 rounded-brand */
}

定義完成後,你同時擁有兩種用法——這就是「雙重身分」:

<!-- 用法 A:utility class(元件裡最常見) -->
<button class="bg-primary text-primary-foreground rounded-brand px-6">
  主要按鈕
</button>

<!-- 用法 B:直接讀 CSS 變數(原生 CSS / 行內 style / JS) -->
<div style="background-color: var(--color-primary)">同一個 Token,用變數取</div>

步驟二:用 semantic 層做明暗與多主題切換

因為元件只引用語義層(bg-primary),換膚時你完全不動元件——只要在不同情境下給語義 Token 不同的值:

/* 深色模式:重新指向語義 Token,元件無感 */
.dark {
  --color-background: oklch(0.15 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);
}

/* 多品牌:同一組結構,只換主色指向 */
[data-brand="ocean"] { --color-primary: oklch(0.55 0.15 240); }
[data-brand="forest"] { --color-primary: oklch(0.55 0.15 150); }

<div class="dark"><div data-brand="ocean"> 包起來,底下所有 bg-primarytext-foreground 就自動換膚——這就是集中管理 Token 的總開關效果。

步驟三:–alpha() 調透明度

自訂 CSS 裡,v4 用 --alpha() 函式調整顏色透明度,它會編譯成現代的 color-mix():

.glow-card {
  /* --alpha():把 brand-500 調成 50% 透明度 */
  border-color: --alpha(var(--color-brand-500) / 50%);
  /* 編譯結果 → color-mix(in oklab, var(--color-brand-500) 50%, transparent) */

  box-shadow: 0 4px 20px --alpha(var(--color-primary) / 30%);
}

如果只是在 HTML 的 utility 裡要透明度,則直接用斜線語法就好,不必寫 --alpha():

<!-- utility 的透明度用斜線,90% 的 primary -->
<button class="bg-primary/90 hover:bg-primary/80">按鈕</button>

步驟四:–spacing() 與任意值引用 CSS 變數

--spacing() 讓你在自訂 CSS 或任意值裡,依 --spacing 基準單位換算間距(取代 v3 的 theme('spacing.4'));而任意值 [...] 與簡寫 (--var) 則讓你把任何 CSS 變數接進 utility:

<!-- 任意值 [var(...)]:引用 Token 變數 -->
<div class="bg-[var(--color-brand-500)]">用完整 var() 語法</div>

<!-- v4 簡寫 (--var):省略 var(),更簡潔 -->
<div class="bg-(--color-brand-500)">簡寫形式</div>

<!-- 簡寫 + 透明度 -->
<div class="bg-(--color-brand-500)/20">20% 透明度的品牌色</div>

<!-- 型別提示:變數型別不明時消歧義 -->
<div class="text-(length:--my-size)">視為字級</div>
<div class="text-(color:--my-color)">視為顏色</div>

<!-- --spacing() 嵌進任意值做混合計算 -->
<div class="py-[calc(--spacing(4)-1px)]">間距減 1px</div>

<!-- Token 是變數,就能和 calc()、light-dark() 組合 -->
<div class="rounded-[calc(var(--radius-brand)-1px)]">圓角減 1px</div>

步驟五:Token 餵給 JavaScript(雙重身分的延伸)

因為 Token 就是 :root 上的 CSS 變數,JavaScript、動畫函式庫能直接讀它,不需要另一套值:

// 讀取計算後的 Token 值(例如給 canvas / 圖表用)
const styles = getComputedStyle(document.documentElement);
const primary = styles.getPropertyValue("--color-primary").trim();

// 動畫函式庫直接吃 Tailwind Token
// <motion.div animate={{ backgroundColor: "var(--color-primary)" }} />

// runtime 動態換主題:改一個變數,全站跟著變
document.documentElement.style.setProperty("--color-primary", "oklch(0.55 0.15 240)");

輸出行為補充:v4 預設「只輸出被用到的 Token」到 :root 以精簡體積。若你需要在 JS 讀取全部 Token,可用 @theme static { … } 強制全部輸出。

常見錯誤與最佳實踐

坑一:過度抽象——做了太多元件級 Token

新手最常見的失誤,是為每個元件的每個狀態都造一個 Token:

/* ❌ 過度細化:維護噩夢 */
@theme {
  --color-button-primary-hover-bg: oklch(...);
  --color-button-primary-active-bg: oklch(...);
  --color-card-header-title-color: oklch(...);
}

這種「元件 Token 爆炸」會讓 Token 表膨脹到沒人看得懂,改一個顏色要在十幾個 Token 間追指向。正確做法是停在語義層就好,讓元件用透明度、狀態變體去衍生變化:

/* ✅ 適度:語義層 + utility 衍生狀態 */
@theme {
  --color-primary: var(--color-brand-600);
  --color-primary-foreground: oklch(1 0 0);
}
/* hover 用透明度衍生,不需要為 hover 另造 Token */
/* <button class="bg-primary hover:bg-primary/90"> */

坑二:Token 命名不一致

命名東拼西湊,是設計系統崩壞的開端:

/* ❌ 命名不一致:層次與角色都看不出來 */
--primary-color: ...;      /* 前綴顛倒,不會生成 utility */
--color-brand: ...;
--btn-bg: ...;

正確做法是固定一套 [命名空間]-[角色]-[變體/狀態] 規範,而且前綴必須用 Tailwind 認得的命名空間(--color-*--spacing-*…),否則不會生成對應 utility:

/* ✅ 統一規範,前綴正確 */
--color-primary: ...;
--color-primary-foreground: ...;
--color-destructive: ...;

坑三:硬編碼值,繞過 Token

在元件裡寫死 bg-blue-600,等於讓這塊視覺永遠脫離設計系統的掌控——換膚時它不會跟著變。只要是會隨主題/品牌變動的視覺,一律走語義 Token(bg-primary),把「決定顏色」的權力集中到 Token 這一個地方。

坑四:忽略 primitive 與 semantic 的分層,把角色直接綁死色號

--color-primary: oklch(0.58 0.14 195) 直接寫死具體值當然能動,但你就失去了「換一個原始色階、整套語義自動跟著換」的彈性。讓 semantic 指向 primitive(--color-primary: var(--color-brand-600)),換膚才能只動一層。

最佳實踐小結

  • 分兩層就好:primitive(調色盤)+ semantic(角色),別急著上元件層。
  • 元件只用語義 Token:bg-primary 而非 bg-blue-600,把總開關集中到 Token。
  • 前綴用對命名空間:--color-*--spacing-*… 前綴錯了就不生成 utility。
  • 狀態用衍生、別造新 Token:hover/active 用 /90 透明度或變體,而非 --*-hover-bg
  • 善用雙重身分:元件用 utility、JS/CSS 用 var(),兩邊同一份 Token 永不失同步。
  • 與設計工具對接:用 Tokens Studio(Figma)+ Style Dictionary 把 Figma Variables 匯出成 @theme--color-* / --spacing-*,讓設計與程式碼共用同一份真相。

小結

這是 TailwindCSS 完整教學 系列的第三十二篇。上一篇 《shadcn/ui》 我們看到它換膚的祕密藏在一組語意化的 CSS 變數;而這一篇,我們把那組變數的本質——設計 Token——徹底攤開來看。回顧幾個重點:

  • Token 分層:primitive(原始色階、無語義)與 semantic(角色別名)分工,換膚只動語義層的指向,元件完全不用改。
  • @theme 雙重身分:v4 把每個 Token 同時變成 :root 上的 CSS 變數與對應的 utility class,元件用 class、JS/CSS 用 var(),永不失同步。
  • 命名空間:--color-*--spacing-*--font-* 等前綴決定生成哪類 utility,是 Token 與 utility 之間的橋樑。
  • v4 專屬語法:--alpha(var(--c) / 50%) 調透明度、--spacing(n) 換算間距、任意值 [var(...)] 與簡寫 (--var) 引用變數,還能加型別提示消歧義。
  • 與 Figma 對接:Tokens Studio + Style Dictionary 讓設計工具與 @theme 共用同一份 Token,實現真正的單一真相源頭。

掌握了設計 Token,你手上就有了整套設計系統的「總開關」——但 Token 只是原料與規則,如何把它們組織成一套可規模化、可協作、能跨團隊維護的完整體系,還需要更上層的架構思維。下一篇 《設計系統建構》 就要帶你把 Token、元件、文件與版本策略串成一套真正能落地的設計系統(Design System),讓一人專案到大型團隊都能穩健成長。

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。

本週主打

AI 自動化入門包

你每天手動在做的那些煩事,其實 AI 可以自己跑。這份給你 10 個照著做就會的自動化工作流 + 50 個複製即用的提示詞,不用會寫程式。

看看這個產品 →