前端技术
#CSS#color-scheme#light-dark#主题切换#设计系统

CSS light-dark() 如何实现主题色自动切换:从 color-scheme 到 UA 样式

设计系统常把浅色与深色主题写进两套自定义属性,再用媒体查询覆盖,维护成本高且容易漏改。本文以组件库主题切换为场景,解释 light-dark() 如何依据 color-scheme 的计算值返回对应颜色,说明它与自定义属性、prefers-color-scheme 媒体查询的差异,并给出显式声明 color-scheme、回退策略与可观测信号的工程做法。

一个组件库主题切换的真实麻烦

假设你维护一套组件库,按钮、卡片、输入框都从一组设计令牌取色:--surface--on-surface--accent。最初的做法是定义两套令牌,再用媒体查询覆盖:

:root {
  --surface: #ffffff;
  --on-surface: #1a1a1a;
}
@media (prefers-color-scheme: dark) {
  :root {
    --surface: #1a1a1a;
    --on-surface: #f5f5f5;
  }
}

这套写法能工作,但每加一个令牌就要在媒体查询里再写一遍,漏掉一个就出现浅色背景配深色文字的局部错误。更麻烦的是“局部强制主题”:产品页希望某个预览区始终用浅色,而页面其余部分跟随系统。媒体查询只能描述“用户偏好”,无法表达“这个子树用哪种配色方案”,于是只能靠额外的类名和重复覆盖来模拟。

light-dark() 换了一个切入点。它不判断用户偏好,而是读取元素上 color-scheme 属性的计算值,据此在两个颜色之间二选一。理解这一点,是理解它全部适用边界的关键。

color-scheme 决定“用哪套”,light-dark() 只负责“取哪个值”

先厘清 color-scheme 的作用。按 CSS Color Adjustment Module Level 1 的定义,这个属性控制浏览器提供的页面 UI(表单控件、滚动条等)是否尊重用户选择的配色方案。它的取值可以是 lightdark,或同时声明 light dark 表示两者都支持。

color-scheme: light dark 时,浏览器会结合 prefers-color-scheme 媒体条件决定实际使用哪套方案;当只写 light 或只写 dark 时,元素被强制为对应方案,用户偏好不再影响它。这个“实际使用”的结果,规范里称为 used color scheme(已使用的配色方案)。

light-dark() 是一个接受两个 <color>(或两个 <image>)的函数。它的规则很短:

  • 已使用的配色方案为 light,或没有偏好时,返回第一个值;
  • 已使用的配色方案为 dark 时,返回第二个值。

MDN 的文档明确写出一个前提:要让 light-dark() 生效,color-scheme 必须被设置为 light dark,通常写在 :root 上。如果页面从未声明 color-scheme,函数不会按你预期切换——这是最常见的“代码没报错但颜色不变”的原因。

:root {
  color-scheme: light dark;
  --surface: light-dark(#ffffff, #1a1a1a);
  --on-surface: light-dark(#1a1a1a, #f5f5f5);
  --accent: light-dark(#0b6bcb, #7fb4ff);
}

和系统颜色(如 CanvasCanvasText)对比会更清楚:系统颜色本身就随已使用的配色方案变化,而 light-dark() 把这个能力开放给作者自定义的颜色。web.dev 的文章指出,在 light-dark() 出现之前,对已使用配色方案做出响应是系统颜色独有的功能。

从用户偏好到最终像素的完整路径

把这条链路摊开,能看清每一步由谁负责。

flowchart TD
  A[操作系统或浏览器设置] --> B[prefers-color-scheme 媒体条件]
  C[作者声明 color-scheme] --> D[计算 color-scheme 值]
  B --> D
  D --> E[已使用的配色方案]
  E --> F[light-dark 选择第一个或第二个值]
  F --> G[自定义属性解析为具体颜色]
  G --> H[绘制到屏幕]
  I[脚本改写元素 color-scheme] --> D

关键转折点在 DEcolor-scheme 的计算值只是“声明支持什么”,真正决定 light-dark() 取哪个值的是“已使用的配色方案”。当声明为 light dark 时,这一步由用户偏好决定;当声明为单一值(如 dark)时,用户偏好被覆盖。

I 这条边是工程上最有价值的部分:脚本可以直接改写某个元素的 color-scheme,从而让 light-dark() 在该子树内切换取值,而无需重写任何颜色令牌。这正是媒体查询方案做不到的局部控制。

与自定义属性加媒体查询方案的差异

两种方案都能实现主题切换,但职责划分不同。下表按工程维度做定性对比,不涉及具体性能数字。

维度自定义属性 + prefers-color-schemelight-dark() + color-scheme
触发依据用户偏好媒体条件元素已使用的配色方案
局部强制主题需额外类名与重复覆盖改写该元素 color-scheme 即可
令牌定义位置分散在基础块与媒体查询块集中在同一处声明
漏改风险每个令牌都要覆盖,易漏每个令牌只写一次二选一
显式前提无需声明 color-scheme必须声明 color-scheme
浏览器支持媒体查询支持面更广较新,需回退
与脚本协作脚本改类名或属性脚本改 color-scheme 值
可读性两处对照才能看出成对关系浅深值相邻,成对关系直观

需要强调,light-dark() 不是媒体查询的替代品。媒体查询还能处理 prefers-contrastforced-colors 等其他偏好,而 light-dark() 只解决“两个颜色二选一”这一件事。设计系统里两者往往共存:用 light-dark() 收敛颜色令牌,用媒体查询处理对比度等更复杂的适配。

用脚本切换主题时,改的是 color-scheme 而不是类名

一个常见需求是:首屏跟随系统,用户点击按钮后手动锁定主题。用 light-dark() 时,脚本只需操作 color-scheme

// 示意逻辑:在 :root 上切换 color-scheme
const root = document.documentElement;

function applyTheme(theme) {
  // theme 为 'light' 或 'dark'
  root.style.colorScheme = theme;
}

// 首次点击时,先读取当前实际生效的方案
function toggleTheme() {
  const current = root.style.colorScheme;
  if (current === 'light' || current === 'dark') {
    applyTheme(current === 'light' ? 'dark' : 'light');
    return;
  }
  // 尚未手动设置过,说明当前跟随系统
  const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
  applyTheme(prefersDark ? 'light' : 'dark');
}

这段逻辑有两个边界要留意。第一,root.style.colorScheme 读取的是内联样式,初始为空字符串,不能用来判断“当前显示的是浅色还是深色”,只能判断“是否被手动锁定过”。判断实际显示效果需要结合 matchMedia。第二,一旦写入内联 color-scheme,就覆盖了样式表里的 light dark,用户后续修改系统设置不会再影响页面,直到清除该内联值。

如果希望“跟随系统”和“手动锁定”可来回切换,需要把内联值清空:

function followSystem() {
  document.documentElement.style.colorScheme = '';
}

清空后,样式表里的 color-scheme: light dark 重新生效,页面回到跟随系统状态。

局部强制主题:把 color-scheme 当作作用域开关

回到开头那个“预览区始终浅色”的需求。有了 light-dark(),只需在该容器上声明 color-scheme: light

:root {
  color-scheme: light dark;
  --surface: light-dark(#ffffff, #1a1a1a);
  --on-surface: light-dark(#1a1a1a, #f5f5f5);
}

.preview-panel {
  color-scheme: light; /* 该子树内 light-dark 一律取第一个值 */
  background: var(--surface);
  color: var(--on-surface);
}

由于 color-scheme 会继承,.preview-panel 内所有使用 --surface 的元素都会取浅色值,无需为每个令牌写覆盖规则。这也解释了为什么 light-dark() 与自定义属性配合得自然:令牌在 :root 上定义一次,作用域切换交给 color-scheme

这里有一个容易忽略的细节。color-scheme 不只影响 light-dark(),还会影响浏览器绘制的表单控件和滚动条。把某个区域强制为 light,该区域内的原生 <input><select> 也会跟着变成浅色外观。如果只想切换自定义颜色、不想改变控件外观,这个副作用需要纳入考虑。

支持边界、回退与可观测信号

MDN 把 light-dark() 标注为 Baseline 2024,自 2024 年 5 月起在最新设备和浏览器版本中可用,并提示旧设备或旧浏览器可能不支持。web.dev 的文章也说明该功能已在三大主要浏览器引擎中提供。具体最低版本号应以各浏览器官方兼容性表为准,本文不逐一列举。

回退策略取决于项目对旧浏览器的容忍度。一种做法是先给出一个默认颜色,再用 @supports 检测:

:root {
  color-scheme: light dark;
  --surface: #ffffff; /* 回退默认值 */
}

@supports (color: light-dark(#000, #fff)) {
  :root {
    --surface: light-dark(#ffffff, #1a1a1a);
  }
}

不支持 light-dark() 的浏览器会忽略 @supports 块内的声明,保留回退值。代价是这些用户只能看到单一主题,因此回退值应选在两种环境下都可读的颜色,并保证前景与背景成对设置,避免出现低对比度组合。规范也提醒,把默认色或系统色与作者指定颜色混搭,无法保证任何特定对比度。

生产环境需要观察的信号包括:

  • 颜色未切换:优先检查 color-scheme 是否真的被声明为 light dark,以及是否被某个祖先元素的单一值覆盖。
  • 局部区域不跟随:检查该子树是否继承了某个 color-scheme 单一值,或存在内联样式。
  • 手动切换后系统设置失效:这是内联 color-scheme 覆盖样式表的预期行为,需要提供“恢复跟随系统”的入口。
  • 控件外观与自定义颜色不一致color-scheme 同时影响 UA 绘制的控件,检查是否在局部作用域里无意改变了它。

诊断时,浏览器开发者工具允许在渲染面板中模拟 prefers-color-scheme,也可以直接查看元素上 color-scheme 的计算值。这两项配合,能快速区分“用户偏好没生效”和“color-scheme 声明有误”两类问题。

什么时候不该用 light-dark()

light-dark() 适合“同一语义令牌在浅深两套方案下各有一个固定值”的场景,比如背景、文字、边框、强调色。它的模型是二选一,因此以下情况需要另作打算:

  • 主题多于两套,或主题由品牌方动态下发。light-dark() 只有两个分支,多主题仍需自定义属性或类名方案。
  • 颜色需要按上下文连续计算,例如从主色派生出悬停、禁用等状态。这类派生更适合相对颜色语法或颜色函数,light-dark() 只能提供两个端点值。
  • 需要根据对比度偏好进一步调整。prefers-contrast 等条件超出 light-dark() 的表达范围,仍需媒体查询配合。

light-dark() 定位为“颜色令牌的二选一求值器”,而不是“主题系统本身”,能避免把它塞进不合适的场景。它真正减少的是成对颜色的重复声明和局部主题覆盖的样板代码;它带来的新约束是必须显式管理 color-scheme,并接受较新的浏览器支持门槛。对设计系统而言,这两点权衡是否划算,取决于旧浏览器占比和主题数量,而不是函数本身是否“先进”。

资料来源

  1. CSS Color Adjustment Module Level 1 - W3C Candidate Recommendation Draft
  2. light-dark() - CSS: Cascading Style Sheets | MDN
  3. Toggle Light and Dark theme with user's OS preference as first using 10 lines of JavaScript - DEV Community
  4. 使用 light-dark() 的 CSS 配色方案相关颜色  |  Articles  |  web.dev