做前端的同学应该都有被暗黑模式支配过的经历。改完了 background、color、border,切到系统暗色模式一看,某块区域还是白得刺眼,于是再补一条 prefers-color-scheme 媒体查询,把颜色又覆盖一遍。但如果你深入过 CSS 规范,会发现浏览器其实早就准备了一层更底层的机制——CSS 系统颜色(CSS System Colors)。它和暗黑模式的关系比很多人想象中更紧密,理解清楚之后,暗黑模式的适配工作能省掉一大半。
这篇就围绕 CSS 系统颜色和暗黑模式的关系及意义展开。适合正在做主题适配的前端开发、对 CSS 了解得比较零散的自学者,也适合想搞懂浏览器如何映射系统外观的进阶读者。我会先讲清楚系统颜色是什么、规范经历了什么变化,再给出完整的实操方案和避坑清单。
1. 从暗黑模式的痛点说起:系统颜色到底解决了什么问题
1.1 我经历过的最原始的暗黑模式适配
早期做暗黑模式,最笨的办法是准备两套样式表,用 JS 切一个 class 到 body 上,甚至直接换 href。后来好一点,用 CSS 变量:
css复制:root {
--bg: #ffffff;
--text: #1a1a1a;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #1a1a1a;
--text: #f2f2f2;
}
}
body {
background: var(--bg);
color: var(--text);
}
这套方案今天依然大量在用,但它有个隐蔽的成本:所有用到的颜色都得抽象成变量,所有用到颜色的地方都得记得去引用变量。一旦漏掉一个 background: #fff,暗黑模式下就会出现刺眼的白色区域。更麻烦的是,表单控件、滚动条、高亮选中这些浏览器“内置”的 UI,默认并不吃你的自定义变量,需要额外一套样式去覆盖。
当时我的真实感受是:暗黑模式不是“一个功能”,而是“一套系统”,因为要覆盖的地方太多太散。后来接触到系统颜色,才发现浏览器本身就在维护一套能跟随系统主题的动态配色,只是很多人没意识到它有多好用。
1.2 系统颜色的底层机制:它从系统里“借颜色”
CSS 系统颜色不是普通颜色,它是一组关键字,比如 Canvas、CanvasText、Field、Highlight。当你写下:
css复制body {
background: Canvas;
color: CanvasText;
}
真正的含义是:背景用“当前系统的画布颜色”,文字用“当前系统的画布文字颜色”。在亮色模式下,浏览器会把 Canvas 解析成接近白色的颜色;在暗色模式下,会自动解析成深灰色。整个过程不需要你写任何媒体查询,因为解析发生在浏览器渲染引擎内部,它直接读取系统当前的主题状态。
用生活化类比来说:普通颜色是“你从颜料管里挤出来的固定颜色”,系统颜色是“一块会根据环境光线自动变色的智能玻璃”。你告诉浏览器“这里放块玻璃”,剩下变色的事交给它。
这也是系统颜色与暗黑模式关系的核心:prefers-color-scheme 只是一个“判断开关”,而系统颜色是“已经根据开关状态选好颜色的面板”。前者需要你手动做条件分支,后者直接给你结果。
1.3 规范变化:从“控件命名”到“用途命名”
很多人不知道,CSS 系统颜色并不是新东西。CSS Color Module Level 3 里就有一批系统颜色,比如 ButtonFace、ButtonText、ActiveBorder、Highlight。但旧规范最大的问题是:这些命名从“控件”角度出发,而不同操作系统对“按钮表面”“边框”的渲染差异巨大,导致开发者在不同平台上看到的效果不一致。
CSS Color Module Level 4 重新整理了系统颜色,把命名从“控件”改成“用途”。你看新的关键字:
Canvas:应用内容区或文档背景。CanvasText:Canvas中的文字。Field:表单输入控件的背景。FieldText:输入控件中的文字。ButtonFace:按钮表面背景。ButtonText:按钮上的文字。Highlight:选中内容的高亮背景。HighlightText:选中内容的高亮文字。
这套命名更贴近“这个元素是做什么的”,而不是“这个元素长得像什么控件”,跨平台的稳定性要比旧版好很多。而且规范明确要求:在暗色模式或高对比度主题下,这些关键字要跟随系统自动变化。所以你可以理解成,暗黑模式的适配,从规范层面就已经被内置进系统颜色的设计里了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 系统颜色和暗黑模式关系的关键节点:color-scheme 与配套属性
2.1 为什么必须写 color-scheme
光用系统颜色还不够,有一件事很容易被忽略:color-scheme 属性。希望系统颜色自动跟随暗黑模式,前提是页面本身告诉浏览器“我支持亮色和暗色”,否则浏览器可能会把整页默认为亮色渲染,UA 的默认表单控件、滚动条、复选框等都会保持亮色调。
color-scheme 可以在 HTML 的 meta 标签里声明:
html复制<meta name="color-scheme" content="light dark">
更推荐的做法是在 CSS 里声明,因为可以按页面局部控制:
css复制:root {
color-scheme: light dark;
}
声明之后,浏览器会把表单控件、滚动条、选中高亮等 UA 默认样式,切换到与系统主题一致的配色。这一步是系统颜色自动生效的前提条件,也是容易踩坑的“第一道门槛”。
2.2 系统颜色速查表与适用场景
我把实际项目里最常用、实用性最高的几个系统颜色整理成了表格,方便快速查阅。注意:具体解析值由浏览器和系统决定,不同平台显示不完全一致,所以不要依赖某个具体色值。
| 关键字 | 代表意义 | 典型使用场景 |
|---|---|---|
Canvas |
应用内容区背景 | 页面整体背景、卡片背景的底层色 |
CanvasText |
应用内容区文字 | 正文、标题、列表文字 |
LinkText |
链接文字 | 页面里的超链接,自动区分访问过状态 |
VisitedText |
已访问链接 | 需要区分访问过的链接时 |
ButtonFace |
按钮表面 | 自定义按钮或原生气泡按钮的背景 |
ButtonText |
按钮文字 | 按钮上的文字、图标 |
Field |
输入框背景 | input、textarea、select 的背景 |
FieldText |
输入框文字 | 输入框里的文字、placeholder 可参考 |
Highlight |
选中内容背景 | 用户拖动选中文字时的背景 |
HighlightText |
选中内容文字 | 选中状态下的文字颜色 |
GrayText |
禁用文字 | 禁用选项、不可点击文字 |
AccentColor |
系统强调色 | 按钮、选中态、焦点态的强调背景 |
AccentColorText |
强调色上的文字 | 强调色背景上的文字或图标 |
Mark |
高亮标记背景 | 类似 mark 元素的背景色 |
MarkText |
高亮标记文字 | 高亮标记中的文字 |
SelectedItem |
列表选中项背景 | 列表、菜单中被选中的行 |
SelectedItemText |
列表选中项文字 | 与 SelectedItem 搭配使用 |
这里我重点说三个日常用得最多的:
第一是 Canvas + CanvasText,适合做页面最底层背景。很多项目不需要花哨的渐变,用系统颜色打底就能自动适配。
第二是 Field + FieldText,适合做表单输入控件。原生的 input、textarea 默认就使用这两个系统颜色,这也是为什么很多时候你不给 input 设背景,它也能在暗黑模式下自动变成深色底、浅色字。
第三是 AccentColor,这个特别有意思。它对应的是操作系统当前的强调色,比如系统主题被设置成蓝色,AccentColor 就解析成蓝色;被设置成紫色,它就解析成紫色。用这个做主要按钮的背景、选中的 checkbox、radio、焦点边框,可以让整个页面跟用户系统的个性化主题保持一致。
2.3 和 prefers-color-scheme 的协作分工
既然系统颜色能自动适配,那 prefers-color-scheme 还有用吗?有,但角色变了。
我的理解是这样:系统颜色负责“自动变化的基础色”,prefers-color-scheme 负责“需要人工决策的精细设计”。比如:
- 阴影在暗色模式下应该更深还是更浅?
- 卡片层级之间的分割线应该用什么透明度的边框?
- 暗色模式下品牌色的饱和度要不要降低?
- 图片上的文字遮罩要不要加重?
这些不是“把颜色换成深色”能解决的,需要设计判断,必须用媒体查询做定向调整。
所以正确的姿势是:能用系统颜色的地方优先用系统颜色,让浏览器接管基础设施;需要品牌色、层级细节、氛围营造的地方,用 CSS 变量定义自己的颜色;最后用 prefers-color-scheme 只做微调和特例。
3. 完整实操:一个自动适配暗黑模式的注册表单卡片
3.1 需求与 HTML 结构
下面我用一个最典型的页面组件来演示:注册表单卡片,包含标题、说明文字、两个输入框、一个按钮、一个选中说明。要求是:亮色模式和暗黑模式下都能正常显示,不用 JS,不用两套变量,尽量少写媒体查询。
HTML 结构很简单:
html复制<section class="card">
<h2>创建账户</h2>
<p class="hint">填写以下信息即可注册</p>
<label class="row">
<span>邮箱</span>
<input type="email" placeholder="you@example.com">
</label>
<label class="row">
<span>密码</span>
<input type="password" placeholder="至少六位">
</label>
<button class="submit">注册</button>
<p class="terms">
<input type="checkbox" id="agree" checked>
<label for="agree">我已阅读并同意服务条款</label>
</p>
</section>
这里我尽力不用多余类名,保持读者能一眼看清结构。CSS 部分才是重点。
3.2 声明颜色方案与系统颜色基线
先声明 color-scheme,再设定基线颜色:
css复制:root {
color-scheme: light dark;
}
body {
background: Canvas;
color: CanvasText;
font-family: system-ui, sans-serif;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
}
注意一个细节:place-items: center 是让卡片在屏幕上居中的最简洁方案,不用单独用 flex 包裹。下面卡片本身:
css复制.card {
width: min(420px, 90vw);
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: 14px;
padding: 24px;
}
这里我没有直接写成 background: Canvas,因为卡片要跟页面背景拉开层次,需要稍亮或者稍暗的二级背景。这个层级差异下面会专门讲。
3.3 用 color-mix 捅破“只有一层背景”的天花板
系统颜色有个显著的局限:Canvas 只有“一层”颜色。换句话说,页面背景和卡片背景如果都用 Canvas,两者会完全一样,没有层级感。在设计稿里,卡片通常要比页面背景亮一点或者暗一点。
解决方案是用 color-mix(),把系统颜色混出一个派生色。color-mix(in srgb, 颜色A 比例, 颜色B 比例) 可以混合两种颜色。我们可以在亮色模式下让卡片比背景暗一点点,在暗黑模式下让卡片比背景亮一点点:
css复制:root {
--card-bg: color-mix(in srgb, Canvas 95%, CanvasText);
--card-border: color-mix(in srgb, Canvas 80%, CanvasText);
}
body {
background: Canvas;
color: CanvasText;
}
.card {
background: var(--card-bg);
border: 1px solid var(--card-border);
}
这里的原理是:用 95% 的 Canvas 和 5% 的 CanvasText 混合。亮色模式下,Canvas 接近白色、CanvasText 接近黑色,混合出来的结果就是“稍微变灰的白”,能跟纯白背景区分开;暗黑模式下,Canvas 接近深灰、CanvasText 接近浅白,混合出来的结果就是“比背景亮一点点的深灰”。两种模式下层级感都出来了,但代码只写了一份。
--card-border 同理,用 20% 的 CanvasText 混出一个带透明感的边框色,比固定写 #ccc 聪明得多,因为暗黑模式下 #ccc 会亮得刺眼,而派生出来的边框色会自动加深。
这个技巧是我实测下来最实用的一个系统颜色进阶用法,比写死两套颜色变量要省事得多。
3.4 prefers-color-scheme 精细微调:阴影与边框
接下来处理阴影和输入框细节。阴影在暗黑模式下不能照搬亮色模式的值,因为暗色模式下阴影本来就看不见,而且过浅的阴影会让卡片像“浮不起来”。这里必须用 prefers-color-scheme 做微调:
css复制.card {
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08);
}
@media (prefers-color-scheme: dark) {
.card {
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4);
}
}
.row input,
.terms input {
background: Field;
color: FieldText;
border: 1px solid var(--card-border);
border-radius: 8px;
padding: 10px 12px;
font-size: 14px;
width: 100%;
box-sizing: border-box;
}
.row span {
display: block;
margin-bottom: 6px;
font-size: 13px;
}
输入框直接用 Field 和 FieldText,好处是用户系统如果开启了高对比度主题,输入框会自动变成系统推荐的样子,不需要额外适配。placeholder 的颜色可以顺手用 GrayText:
css复制.row input::placeholder {
color: GrayText;
}
这里顺便解决一个老被搜索的问题——“css中怎么把input居中”。很多人以为 input 不居中是因为没有特殊的居中属性,其实只需要两件事:给父容器设置 width: 100%,再让 input 自己 display: block; margin: 0 auto;,或者用 box-sizing: border-box 配合父容器内边距。表单组件尽量不要全局设置居中,而是在布局容器层面解决,这样不会破坏 label 和输入框的纵向结构。
3.5 按钮和强调色:把系统主题穿在身上
登录按钮我故意演示两种思路。如果你要品牌感强,那按钮用品牌色变量;如果你希望按钮跟用户系统主题感官统一,可以用 AccentColor 和 AccentColorText:
css复制.submit {
width: 100%;
padding: 12px;
border: none;
border-radius: 8px;
background: AccentColor;
color: AccentColorText;
font-size: 15px;
font-weight: 600;
cursor: pointer;
}
.terms input[type="checkbox"] {
accent-color: AccentColor;
}
accent-color 是控制 checkbox、radio、range、progress 这类控件强调色的属性,不需要重绘控件,直接让浏览器把勾选状态渲染成指定颜色。用 AccentColor 作为它的值,可以让复选框在暗黑模式下也保持视觉协调。
按钮用 AccentColor 还有一个隐藏好处:用户如果重新设置过系统主题色,按钮颜色会跟着变,页面会呈现出一种“跟系统长在一起”的协调感。当然,如果你的产品有强品牌色需求,按钮还是建议用自定义变量,AccentColor 更适合做辅助强调或者非核心交互按钮。
完整的样式加在一起,没有写一句“两套主题”的样式分支,除了 prefers-color-scheme 那一小段阴影微调。这就是系统颜色的价值。
4. 常见问题与排查技巧实录
4.1 系统颜色“没生效”的三种原因
我在实际项目里遇到过多次系统颜色不生效的情况,总结下来主要是三类:
第一种:没有声明 color-scheme。这是最常见的原因。页面默认是亮色渲染,即便你用 Canvas 写背景,某些浏览器也会把它解析成亮色值,显示不出暗黑效果。解决办法就是在 :root 上加一句 color-scheme: light dark;。
第二种:用了废弃的旧关键字。ButtonHighlight、ActiveCaption、AppWorkspace 这些都是旧关键字,新浏览器虽然部分兼容,但行为不一致。建议直接换成新规范里的通用关键字。写系统颜色前先查一下该关键字是否 deprecated,免得白折腾。
第三种:浏览器版本太老。低于 2021 年前后主流版本的浏览器,对新系统颜色关键字的支持不完整。可以用 @supports (background: Canvas) 做探测,不支持时回退到 CSS 变量方案:
css复制body {
background: #fff;
color: #1a1a1a;
}
@supports (background: Canvas) {
body {
background: Canvas;
color: CanvasText;
}
}
这个方式可以确保老浏览器不裸奔。
4.2 按钮和表单控件在不同平台下的差异
强调一次:系统颜色在不同平台解析出来的色值不一样。拿 ButtonFace 举例,macOS 上暗黑模式解析出来是偏蓝灰的深色,Windows 上可能是偏黑灰的深色,Linux 桌面环境差异更大。如果你的设计稿对按钮颜色要求很精准,不建议使用系统颜色做品牌按钮;但如果你的页面本来就想贴近“原生感”,那这种差异反而是一种优势。
表单控件也一样。Chrome 和 Safari 对 Field 的处理存在差异,Safari 在暗黑模式下可能还会给输入框加一层淡淡的阴影,导致看起来比 Chrome 更立体。这些细节只能靠真机测试校准,不能指望一套代码在每个平台显示完全一致。
4.3 选中态颜色:Highlight 还是 SelectedItem
还有一个小细节值得单独讲:文字选中的背景色,规范里有两个长得非常像的关键字——Highlight 和 SelectedItem。区别在于:Highlight 对应的是用户临时拖动选中的文字背景,SelectedItem 对应的则是列表、菜单中常驻的“选中项”背景。
如果你希望让 ::selection 选中文字时使用系统选中色:
css复制::selection {
background: Highlight;
color: HighlightText;
}
理论上浏览器默认就是这样实现的,所以很多时候不写也能得到一致效果。但如果你给 ::selection 设置了自定义颜色,又想在某些场景下恢复“原生手感”,知道这两个关键字区别就有用了。现代浏览器普遍都支持 SelectedItem,但老一点的浏览器只认 Highlight,稳妥写法是两个都写上,后者回退。
4.4 打印模式和低对比度场景
系统颜色一个容易被人忽略的问题出现在打印场景。打印时浏览器默认会把背景色去掉,这时如果文字颜色是 CanvasText、背景是 Canvas,打印出来没有问题,因为文字颜色会保留;但如果你依赖 color-mix 混出来的浅色背景做区块区分,打印时会显得很淡。
建议在打印样式里把系统颜色值显式覆盖成高对比度的黑白方案:
css复制@media print {
body {
background: #ffffff !important;
color: #000000 !important;
}
}
另外,系统颜色在“用户高对比度模式”下表现很好,比如 Windows 的高对比度主题,Canvas 会被解析成纯黑,CanvasText 会被解析成纯白,这对可访问性非常友好。开发时不要因为“系统颜色看起来不够精致”就全部替换掉,保留一部分原生语义,反而是对弱视用户更负责任的做法。
4.5 问题排查速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 切了系统暗黑模式,页面还是亮色 | 没声明 color-scheme |
在 :root 加 color-scheme: light dark; |
Canvas 背景跟预期差别大 |
不同浏览器解析差异 | 用 color-mix 做二次调整,或接受系统差异 |
按钮用 ButtonFace 显示难看 |
平台渲染差异太大 | 品牌按钮用自定义变量,原生控件用系统色 |
| 选中文字没有跟随系统 | 被自定义 ::selection 覆盖 |
用 Highlight 配合回退 |
| 暗黑模式下卡片背景不统一 | 写死了十六进制颜色 | 改用 color-mix(in srgb, Canvas x%, CanvasText) |
| 老浏览器显示错误 | 系统颜色不被识别 | 用 @supports 做回退 |
| 打印预览缺少背景色 | 浏览器默认去背景 | 打印媒体查询里显式设置高对比颜色 |
最后说一个我个人的使用经验:现在我做新项目,已经不太喜欢一开始就铺满整套 CSS 变量了。我会先把 color-scheme 写上,把页面骨架用系统颜色搭出来,再观察哪些地方需要品牌色、哪些地方需要层次感,然后针对性地补自定义变量和 prefers-color-scheme 微调。这么做的感受是,暗黑模式不再是一件“额外要做的任务”,而是页面天生就具备的底层能力。如果你也被两套主题的维护成本搞得头大,我建议你也试试先把系统颜色用起来,再决定哪些地方需要自己接管。你会发现,很多时候浏览器替我们想得已经足够远。
