雙主題的難度不在寫兩套顏色,而在於顏色的名字。用 --gray-100 命名的系統,在暗色主題會逼出 --gray-100: #1a1a1a 這種自相矛盾的宣告;用 --surface-raised 命名的系統,兩套主題各給一個值就結束了。這一篇處理的就是這件事:把顏色從「它長什麼樣」改成「它是做什麼用的」。
三層 token 架構
實務上最耐用的結構是三層,每層只做一件事:
:root {
/* ── 第 1 層:原始色階(primitive)。永遠不變,不隨主題切換 ── */
--blue-50: #eef4ff;
--blue-500: #3b6fd4;
--blue-300: #8fb3f0;
--gray-0: #ffffff;
--gray-950: #0e1013;
--gray-900: #14161a;
--gray-100: #eef0f3;
/* ── 第 2 層:語意 token(semantic)。主題切換只改這一層 ── */
color-scheme: light dark;
--bg-canvas: light-dark(var(--gray-0), var(--gray-950));
--bg-surface: light-dark(var(--gray-0), var(--gray-900));
--bg-subtle: light-dark(var(--gray-100), #1c1f24);
--text-primary: light-dark(#16191d, #e6e8eb);
--text-muted: light-dark(#5b6470, #9aa4b2);
--border-subtle: light-dark(#e2e6ea, #262a30);
--accent: light-dark(var(--blue-500), var(--blue-300));
--accent-text: light-dark(#ffffff, #0e1013);
/* ── 第 3 層:元件 token(component)。指向第 2 層,不直接指向第 1 層 ── */
--card-bg: var(--bg-surface);
--card-border: var(--border-subtle);
--button-bg: var(--accent);
--button-text: var(--accent-text);
}規則只有一條,但必須嚴格執行:元件 CSS 只能引用第 2、3 層,永遠不准直接引用第 1 層或字面顏色值。 一旦有人在元件裡寫了 color: var(--gray-900),暗色主題就會出現一個沒人記得的黑字。
第 1 層的存在價值是讓設計師能討論色階、讓對比計算有共同基準;它不參與主題切換,所以不需要 light-dark()。
語意 token 該切多細
切太粗會不夠用(所有背景共用一個值,卡片和頁面分不開層次),切太細會沒人記得住。以下這組覆蓋率高、名字不會吵架,可以當起點:
| 類別 | token | 用途 |
|---|---|---|
| 背景 | --bg-canvas | 頁面最底層 |
--bg-surface | 卡片、面板、對話框 | |
--bg-raised | 浮起來的層(下拉、tooltip、浮動列) | |
--bg-subtle | 次要區塊、表頭、程式碼區塊 | |
--bg-hover / --bg-active | 互動狀態 | |
| 文字 | --text-primary | 正文 |
--text-muted | 次要說明、時間戳 | |
--text-disabled | 停用 | |
--text-on-accent | 疊在主色上的文字 | |
| 邊框 | --border-subtle / --border-strong | 分隔線 vs 明確邊界 |
| 語意色 | --success / --warning / --danger / --info | 各自要有 -bg 與 -text 兩個變體 |
| 焦點 | --focus-ring | 鍵盤焦點外框,兩套主題都要達 3:1 |
--success 這類語意色一定要拆成前景與背景兩個 token。原因是暗色主題下,你不能把亮色主題那個 #d4f7dd 的淺綠底直接搬過去,也不能把 #1a7f37 的深綠字直接搬過去——兩者在暗背景上分別是刺眼與看不見。實務上的做法是:
--success-bg: light-dark(#e7f6ec, #10281a);
--success-border: light-dark(#a8dcbb, #1f5233);
--success-text: light-dark(#1a7f37, #6ee79b);亮色的前景是深色、背景是淺色;暗色的前景是去飽和後的亮色、背景是低明度的同色相。這個對稱關係是可以規則化的,見下一節。
從亮色推導暗色的可操作規則
暗色主題不是把亮色的明度反轉。逐條可執行的轉換規則:
- 降飽和度。 高飽和色在深底上會產生視覺震盪(optical vibration),而且多半過不了對比門檻。Material 的暗色指引直接建議避免飽和色、改用去飽和版本。實作上把 HSL 的 S 降 10–25%、L 拉高到能過 4.5:1 為止。
- 主色要變亮,不是變暗。 亮色主題的品牌色(例如
--blue-500)在暗底上通常對比不足,暗色主題要往色階的淺端取(--blue-300)。這與直覺相反,但這正是「亮色的 500 不等於暗色的 500」的意思。 - 背景層次靠明度增量,不靠陰影。 亮色主題用陰影表現「浮起來」,暗色主題陰影幾乎看不見,層次必須靠背景變亮。詳見 對比、暗色不是純黑、高程與陰影。
- 邊框在暗色下要更明顯一點。 亮色主題的
#e5e7eb分隔線換算到暗色若只用#1f2226,多數螢幕在低亮度下會完全看不到。暗色的邊框對比通常需要比亮色高一些。 - 語意色的色相不能換。 綠還是綠、紅還是紅。可以調飽和與明度,換色相會破壞語意記憶。
用 CSS 相對顏色語法減少手工值
oklch() 與相對顏色語法可以把上面的規則寫成公式,而不是一堆手調的 hex:
:root {
--brand: oklch(0.55 0.16 258); /* 單一來源 */
/* 從 --brand 推導出兩套主題各自的用色 */
--accent: light-dark(
var(--brand),
oklch(from var(--brand) 0.78 calc(c * 0.75) h) /* 更亮、更去飽和 */
);
}oklch 的優點是 L 通道接近感知明度,調整 L 時色相不會漂移,這讓「拉亮到過對比為止」變成可預期的操作。代價是瀏覽器支援比 hex 新,且相對顏色語法(from)比 oklch() 本身更新——生產環境用之前要確認目標瀏覽器矩陣,或用建構期工具(Lightning CSS、PostCSS)降級成靜態值。
用 attribute 而非 class 承載主題
手動切換時,主題狀態放在 <html> 的屬性上,而不是 class:
:root { color-scheme: light dark; } /* 預設跟隨系統 */
:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"] { color-scheme: dark; }用 data-theme 而不是 class="dark" 有兩個實際好處:三態(未設定 / light / dark)可以用「屬性存在與否」自然表達,不需要靠兩個 class 互斥;以及不會跟工具類 CSS 框架的 class 打架。切換器的完整實作見 三態切換器與偏好儲存。
關鍵是切換時要一起改 color-scheme,不能只改自訂屬性。只改自訂屬性的話,捲軸、<select> 下拉、日期選擇器、autofill 底色都還停在系統配置——這是「手動切成暗色,但輸入框還是白的」這個 bug 的唯一原因。
遷移既有專案的順序
面對一份充滿寫死顏色的舊 CSS,可行的順序是:
- 盤點。
grep -Eo '#[0-9a-fA-F]{3,8}|rgba?\([^)]*\)' -r src/導出全部字面顏色與出現次數,通常會發現 200 個值裡真正不同的只有 20 個。 - 收斂成第 1 層。 把近似色合併(
#333、#343434、#353535合成一個),這步就能砍掉一半。 - 依用途命名成第 2 層。 這步要看使用現場,不能只看顏色值——同一個
#e5e7eb在分隔線和停用背景是兩個 token。 - 全域替換,暗色值先全部填
light-dark(x, x)。 先讓兩套主題長得一樣、確保沒改壞任何東西,再逐一調暗色值。這個中間狀態是可上線的,是這次遷移能拆成小步的關鍵。 - 最後才加切換器。 切換器是最容易寫的部分,先加它只會讓你在 token 還沒好的時候看到一堆破圖。