實作雙主題的 CSS 有三種寫法,差別不在效果而在三態切換與 fallback 的成本。以下三種都給完整可貼上的程式碼,並說明各自在什麼情況會壞掉。

寫法 A:媒體查詢 + 屬性覆寫(相容性最好)

最保守、支援度最廣的寫法,是把顏色值放進自訂屬性,用媒體查詢處理系統偏好、用屬性選擇器處理手動覆寫:

:root {
  color-scheme: light dark;
 
  /* 亮色為預設 */
  --bg-canvas:    #ffffff;
  --bg-surface:   #f7f8fa;
  --text-primary: #16191d;
  --border:       #e2e6ea;
}
 
/* 系統偏好暗色,且使用者沒有手動指定 */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --bg-canvas:    #0e1013;
    --bg-surface:   #14161a;
    --text-primary: #e6e8eb;
    --border:       #262a30;
  }
}
 
/* 使用者手動指定暗色,不管系統偏好 */
:root[data-theme="dark"] {
  color-scheme: dark;
  --bg-canvas:    #0e1013;
  --bg-surface:   #14161a;
  --text-primary: #e6e8eb;
  --border:       #262a30;
}
 
:root[data-theme="light"] { color-scheme: light; }

重複的暗色值可以用 CSS 巢狀或建構工具消掉,但邏輯上的三段(預設亮 / 系統暗且未覆寫 / 手動暗)不能省。省掉 :not([data-theme="light"]) 會導致「系統是暗色時,使用者按不動『亮色』」這個 bug——媒體查詢與屬性選擇器的優先級是後者勝出,但媒體查詢區塊如果寫在後面且選擇器同權重,順序就會贏,這種順序相依的錯誤在大型樣式表裡極難追。用 :not() 讓兩者互斥,就不必依賴宣告順序。

寫法 B:light-dark()(最少重複)

light-dark() 讓同一個值同時攜帶兩套主題,重複量降到最低:

:root {
  color-scheme: light dark;
 
  --bg-canvas:    light-dark(#ffffff, #0e1013);
  --bg-surface:   light-dark(#f7f8fa, #14161a);
  --text-primary: light-dark(#16191d, #e6e8eb);
  --border:       light-dark(#e2e6ea, #262a30);
}
 
/* 三態切換只需要改 color-scheme,值不用重寫 */
:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"]  { color-scheme: dark;  }

這是它最漂亮的地方:手動切換退化成只改一個屬性,所有 light-dark() 會自動跟著換分支。前一種寫法要維護三份顏色清單,這種只要一份。

必要條件與限制:

  • color-scheme 必須有值,否則 light-dark() 一律回傳第一個(亮色)參數。這是最常見的「我用了 light-dark() 但暗色沒作用」原因。
  • Baseline newly available,2024 年 5 月起在各主流瀏覽器可用;依 Baseline 的 30 個月規則,約要到 2026 年 11 月才會進入 widely available。也就是說直到現在,舊裝置與長尾瀏覽器仍需 fallback。
  • 顏色值支援良好;影像值(url()、漸層)支援較不一致,要用 @supports 保護:
@supports (background-image: light-dark(url("a.png"), url("b.png"))) {
  .hero { background-image: light-dark(url("hero-light.webp"), url("hero-dark.webp")); }
}
  • 解析時機的陷阱light-dark() 依「使用該值的元素」的 color-scheme 求值,不是依宣告它的元素。把 token 定義在 :root、卻在一個 color-scheme: only light 的子樹裡使用,拿到的會是亮色分支。這其實是正確且有用的行為(局部反轉主題很方便),但除錯時容易誤判成「變數沒生效」。

寫法 C:兩份樣式表 + media 屬性(無 JS 也能三態)

把顏色值拆成獨立檔案,用 <link media> 控制:

<link rel="stylesheet" href="/base.css">
<link rel="stylesheet" href="/theme-light.css" media="(prefers-color-scheme: light)">
<link rel="stylesheet" href="/theme-dark.css"  media="(prefers-color-scheme: dark)">

切換時 JS 只改 media 屬性:強制亮色就把 light 那份設成 all、dark 那份設成 not all,回到自動就還原成原本的媒體查詢字串。優點是首屏不需要等 JS 就已經正確;缺點是兩份檔案都會被下載(不匹配的那份優先級較低但仍會抓),而且切換瞬間若檔案還沒進快取會有延遲。

實務上這個寫法適合純靜態站,中大型應用用 A 或 B 較好維護。

建議的組合:B 為主、A 為 fallback

要同時拿到 B 的維護性與 A 的相容性,用特性偵測分流:

:root {
  color-scheme: light dark;
  /* fallback:舊瀏覽器只看得懂這組(亮色) */
  --bg-canvas:    #ffffff;
  --text-primary: #16191d;
}
 
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --bg-canvas:    #0e1013;
    --text-primary: #e6e8eb;
  }
}
 
/* 支援 light-dark() 的瀏覽器改用單一來源,覆蓋上面兩段 */
@supports (color: light-dark(#000, #fff)) {
  :root {
    --bg-canvas:    light-dark(#ffffff, #0e1013);
    --text-primary: light-dark(#16191d, #e6e8eb);
  }
  :root[data-theme="light"] { color-scheme: light; }
  :root[data-theme="dark"]  { color-scheme: dark;  }
}

或者更省事:直接讓 Lightning CSS / PostCSS 在建構期把 light-dark() 展開成媒體查詢版本,原始碼只維護一份 B 寫法。這是目前最推薦的做法——原始碼寫現代語法,降級交給工具

過場動畫:不要對整頁做 transition

主題切換時對 background-colorcolor 加 transition 看起來很順,但在整頁套用會造成一次大範圍重繪,低階裝置上會明顯掉幀,而且切換期間文字與背景可能同時處於中間色,短暫對比不足。

比較穩的做法是限定範圍與時間,並尊重動態減量偏好:

@media (prefers-reduced-motion: no-preference) {
  :root { transition: background-color 150ms ease, color 150ms ease; }
}

更進階可以用 View Transitions API 做一次性的整頁交叉淡入,避免逐元素 transition 的成本。這屬於錦上添花,不要讓它擋住基本正確性。

常見的實作級錯誤

  • 只改自訂屬性、沒改 color-scheme 原生控件與捲軸不會跟著換。這是最高頻的錯誤。
  • color-scheme 寫在 body 而不是 :root 畫布底色由根元素決定,寫在 body 上會讓 overscroll 區域露出錯誤顏色。
  • 暗色寫成預設、亮色靠媒體查詢覆寫。 不支援媒體查詢或查詢失效的環境(部分 in-app webview)會固定拿到暗色。
  • light-dark() 寫在 @media (prefers-color-scheme: dark) 裡面。 這是雙重判斷,結果只會更難預測,沒有任何好處。
  • 忘記 <meta name="color-scheme"> CSS 生效前的那幾十毫秒仍是預設白,見 避免主題閃爍

下一步:三態切換器與偏好儲存