一个组件库主题切换的真实麻烦
假设你维护一套组件库,按钮、卡片、输入框都从一组设计令牌取色:--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(表单控件、滚动条等)是否尊重用户选择的配色方案。它的取值可以是 light、dark,或同时声明 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);
}
和系统颜色(如 Canvas、CanvasText)对比会更清楚:系统颜色本身就随已使用的配色方案变化,而 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
关键转折点在 D 到 E:color-scheme 的计算值只是“声明支持什么”,真正决定 light-dark() 取哪个值的是“已使用的配色方案”。当声明为 light dark 时,这一步由用户偏好决定;当声明为单一值(如 dark)时,用户偏好被覆盖。
I 这条边是工程上最有价值的部分:脚本可以直接改写某个元素的 color-scheme,从而让 light-dark() 在该子树内切换取值,而无需重写任何颜色令牌。这正是媒体查询方案做不到的局部控制。
与自定义属性加媒体查询方案的差异
两种方案都能实现主题切换,但职责划分不同。下表按工程维度做定性对比,不涉及具体性能数字。
| 维度 | 自定义属性 + prefers-color-scheme | light-dark() + color-scheme |
|---|---|---|
| 触发依据 | 用户偏好媒体条件 | 元素已使用的配色方案 |
| 局部强制主题 | 需额外类名与重复覆盖 | 改写该元素 color-scheme 即可 |
| 令牌定义位置 | 分散在基础块与媒体查询块 | 集中在同一处声明 |
| 漏改风险 | 每个令牌都要覆盖,易漏 | 每个令牌只写一次二选一 |
| 显式前提 | 无需声明 color-scheme | 必须声明 color-scheme |
| 浏览器支持 | 媒体查询支持面更广 | 较新,需回退 |
| 与脚本协作 | 脚本改类名或属性 | 脚本改 color-scheme 值 |
| 可读性 | 两处对照才能看出成对关系 | 浅深值相邻,成对关系直观 |
需要强调,light-dark() 不是媒体查询的替代品。媒体查询还能处理 prefers-contrast、forced-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,并接受较新的浏览器支持门槛。对设计系统而言,这两点权衡是否划算,取决于旧浏览器占比和主题数量,而不是函数本身是否“先进”。