prefers-color-schemecolor-scheme 名字相近,作用方向完全相反,混淆這兩者是雙主題實作最常見的根本錯誤。前者是媒體查詢,方向是「瀏覽器告訴你使用者想要什麼」;後者是CSS 屬性,方向是「你告訴瀏覽器這份文件支援什麼」。只寫前者的網站會出現白色捲軸配深色頁面、下拉選單是亮的、輸入框是白底的這類「一半沒換」的畫面——因為瀏覽器從沒被告知它可以用暗色去畫那些原生元件。

prefers-color-scheme:讀取偏好

值只有 lightdark 兩個。規範早期的 no-preference 已被移除,現在「沒有明確偏好」會回報為 light。這代表你無法用 CSS 區分「使用者選了亮色」與「使用者沒選」——如果你的產品需要這個區別,只能靠自己的儲存狀態,見 三態切換器與偏好儲存

/* 亮色為基準,暗色覆寫 */
.card { background: #fff; color: #1a1a1a; }
 
@media (prefers-color-scheme: dark) {
  .card { background: #1c1c1e; color: #f2f2f7; }
}

Baseline 狀態:widely available,2020 年 1 月起跨瀏覽器可用。實務上不需要 fallback,只需要注意在完全不支援的環境下媒體查詢不成立,因此把亮色寫在查詢外面當預設是比較安全的寫法(反過來把暗色寫成預設,會讓不支援的環境拿到暗色而使用者沒得選)。

JS 端對應的讀法與監聽:

const mq = window.matchMedia('(prefers-color-scheme: dark)');
mq.matches;                              // 目前是否偏好暗色
mq.addEventListener('change', (e) => {   // 使用者在系統切換時即時反應
  applyTheme(e.matches ? 'dark' : 'light');
});

addEventListener 是現在的正確寫法,舊教學裡的 mq.addListener 已廢棄。忘記監聽 change 是「使用者在系統設定裡切換,但頁面沒跟著變」這個 bug 的直接原因,尤其在 iOS 自動日夜切換與 Android 的排程暗色下很容易被抓到。

color-scheme:宣告能力

color-scheme 決定 UA 用哪一套預設去畫你沒有畫的東西。MDN 列出它影響四類:畫布(canvas surface)底色、捲軸與互動 UI 的預設色、表單控件的預設色、以及瀏覽器提供的 UI(例如拼字檢查底線)。

:root {
  color-scheme: light dark;   /* 兩套都支援,順序代表偏好 */
}

可用值與語意:

意義
normal不表態,使用頁面預設
light只用亮色配置
dark只用暗色配置
light dark兩者皆可,亮色優先
dark light兩者皆可,暗色優先
only light禁止 UA 覆寫(可關掉 Chrome Auto Dark Theme)

color-scheme可繼承屬性,所以可以做局部覆寫:

header { color-scheme: only light; }
main   { color-scheme: light dark; }
footer { color-scheme: only dark; }

Baseline 狀態:widely available,2022 年 1 月起

meta 版本:為什麼一定要有

同樣的宣告有 HTML 版本:

<meta name="color-scheme" content="light dark">

MDN 明確建議把它放在任何 CSS 樣式資訊之前,理由是避免載入期間的畫面閃爍。差別在時機:CSS 裡的 :root { color-scheme } 要等樣式表下載並套用才生效,而 meta 在 HTML parser 讀到的那一刻就生效。對外部樣式表阻塞的頁面來說,這中間有一段時間畫布會是預設白色——這就是很多人以為自己已經處理好、實際上還是會白閃一下的原因。

content 的值與 CSS 相同,但 only dark無效值:規範不允許在文件不支援亮色時強制暗色。

兩者一起用才是完整的一組

正確的最小組合是三件事同時到位:

<!doctype html>
<html lang="zh-Hant">
<head>
  <meta charset="utf-8">
  <meta name="color-scheme" content="light dark">   <!-- ① 早期宣告 -->
  <link rel="stylesheet" href="/style.css">
</head>
:root {
  color-scheme: light dark;                          /* ② CSS 宣告,供後續 light-dark() 使用 */
  --surface: light-dark(#ffffff, #14161a);
  --text:    light-dark(#1a1d21, #e6e8eb);
}
 
body { background: var(--surface); color: var(--text); }  /* ③ 自己畫的部分 */

少了 ①②,原生控件與捲軸不會換色;少了 ③,只有 UA 元件換色而你的版面沒換。這兩種「只換一半」的畫面,是雙主題最容易被使用者發現的破綻。

一個常被忽略的行為:canvas 底色

color-scheme 生效且系統偏好暗色時,UA 會把畫布底色改成深色。這代表即使你完全沒寫 body { background },頁面也會是深色的。反過來說,如果你在 body 上寫死了 background: #fff,而 html 沒寫,那麼 iOS Safari 的橡皮筋回彈區域、以及超出 body 高度的區域,會露出 UA 的深色畫布——出現「內容白、上下黑」的夾心畫面。解法是把主背景色也套到 html 上,細節見 Mobile web 的差異

與 iframe、SVG 的關係

嵌入的 <iframe> 與 SVG 內部,prefers-color-scheme 會依父層的 color-scheme 求值。這在兩個場景會咬人:一是第三方嵌入(YouTube、地圖、金流表單)跟著你的宣告走,但它們自己的實作未必支援;二是外連的 .svg 檔案有自己的樣式脈絡,用 currentColor 會比在 SVG 內寫媒體查詢可靠得多,見 圖片、圖示與 media

下一步:語意化顏色 token 設計:不要寫死 hex