雙主題的難度不在寫兩套顏色,而在於顏色的名字。用 --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);

亮色的前景是深色、背景是淺色;暗色的前景是去飽和後的亮色、背景是低明度的同色相。這個對稱關係是可以規則化的,見下一節。

從亮色推導暗色的可操作規則

暗色主題不是把亮色的明度反轉。逐條可執行的轉換規則:

  1. 降飽和度。 高飽和色在深底上會產生視覺震盪(optical vibration),而且多半過不了對比門檻。Material 的暗色指引直接建議避免飽和色、改用去飽和版本。實作上把 HSL 的 S 降 10–25%、L 拉高到能過 4.5:1 為止。
  2. 主色要變亮,不是變暗。 亮色主題的品牌色(例如 --blue-500)在暗底上通常對比不足,暗色主題要往色階的淺端取(--blue-300)。這與直覺相反,但這正是「亮色的 500 不等於暗色的 500」的意思。
  3. 背景層次靠明度增量,不靠陰影。 亮色主題用陰影表現「浮起來」,暗色主題陰影幾乎看不見,層次必須靠背景變亮。詳見 對比、暗色不是純黑、高程與陰影
  4. 邊框在暗色下要更明顯一點。 亮色主題的 #e5e7eb 分隔線換算到暗色若只用 #1f2226,多數螢幕在低亮度下會完全看不到。暗色的邊框對比通常需要比亮色高一些。
  5. 語意色的色相不能換。 綠還是綠、紅還是紅。可以調飽和與明度,換色相會破壞語意記憶。

用 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,可行的順序是:

  1. 盤點。 grep -Eo '#[0-9a-fA-F]{3,8}|rgba?\([^)]*\)' -r src/ 導出全部字面顏色與出現次數,通常會發現 200 個值裡真正不同的只有 20 個。
  2. 收斂成第 1 層。 把近似色合併(#333#343434#353535 合成一個),這步就能砍掉一半。
  3. 依用途命名成第 2 層。 這步要看使用現場,不能只看顏色值——同一個 #e5e7eb 在分隔線和停用背景是兩個 token。
  4. 全域替換,暗色值先全部填 light-dark(x, x) 先讓兩套主題長得一樣、確保沒改壞任何東西,再逐一調暗色值。這個中間狀態是可上線的,是這次遷移能拆成小步的關鍵。
  5. 最後才加切換器。 切換器是最容易寫的部分,先加它只會讓你在 token 還沒好的時候看到一堆破圖。

下一步:CSS 實作:變數切換、light-dark() 與 fallback