實作雙主題的 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-color 與 color 加 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 生效前的那幾十毫秒仍是預設白,見 避免主題閃爍。
下一步:三態切換器與偏好儲存。