颜色与暗色模式
给网站加暗色模式,写一组变量、切换一个属性,半天就能“做完”。真正花时间的是上线后的一连串反馈:
- 每次刷新页面,都先闪一下白色再变暗;
- 页面是暗的,但滚动条、输入框、下拉框还是刺眼的白色;
- 某些图标、按钮、第三方组件就是不变色;
- 灰色的次要文字在暗背景上几乎看不清。
这篇讲清暗色模式的实现思路,并逐个解决这些问题。
读完这篇你会
- 理解为什么暗色模式不能简单反色
- 实现“跟随系统 + 手动切换 + 记住选择”的主题方案
- 修复主题闪烁、原生控件不变色、图标写死颜色、对比度不足等问题
1. 原理:暗色模式是第二套配色,不是反色
最省事的想法是把颜色取反:白变黑、黑变白。但这样会出现一连串问题:
| 直接反色的后果 | 为什么 |
|---|---|
| 照片变成底片效果 | 图片的颜色也被反转了 |
| 品牌色变得刺眼或发灰 | 高饱和的颜色在暗背景上显得更亮、更“跳” |
| 阴影看不见 | 黑色阴影投在黑色背景上 |
| 层次消失 | 亮色模式靠阴影区分层级,暗色下这招失效 |
正确的做法是为暗色模式单独设计一套语义颜色:背景用深灰而不是纯黑,文字用浅灰而不是纯白,品牌色适当提亮、降低饱和度,层级靠“越靠上的表面越亮”来区分。
选对颜色的表示方法
| 写法 | 例子 | 适合 |
|---|---|---|
| HEX | #123642 | 最常见,适合直接存色值 |
| RGB | rgb(18 54 66 / 0.5) | 需要透明度时 |
| HSL | hsl(195 57% 16%) | 手动调同色系的深浅 |
| OKLCH | oklch(0.35 0.05 220) | 生成整套色阶:同样的亮度值,看起来亮度也真的相同 |
HSL 的问题是“明度”和人眼感受不一致:hsl(60 100% 50%)(黄)和 hsl(240 100% 50%)(蓝)明度都是 50%,但黄色看起来亮得多。生成暗色模式色阶时用 OKLCH,对比度更容易控制。
2. 实现:语义变量 + 主题属性
css
:root {
--color-bg: #ffffff;
--color-surface: #f4f6f5;
--color-text: #13262e;
--color-text-muted: #55676d;
--color-border: #dde4e1;
--color-primary: #167f84;
}
:root[data-theme='dark'] {
--color-bg: #0f1a1f; /* 深灰,不是纯黑 */
--color-surface: #17252b; /* 越“靠上”的表面越亮 */
--color-text: #e3ece9; /* 浅灰,不是纯白 */
--color-text-muted: #9fb2b6;
--color-border: #2a3b42;
--color-primary: #4fb3b5; /* 品牌色提亮,保证在暗背景上可读 */
}组件只引用语义变量(var(--color-text)),不关心当前是什么主题。变量的分层设计见 CSS 变量与主题系统。
跟随系统,同时允许手动切换
用户的选择通常有三种:浅色、深色、跟随系统。
css
/* 用户没有手动选择时,跟随系统 */
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) {
--color-bg: #0f1a1f;
/* ……其余暗色变量,与上面 [data-theme='dark'] 保持一致 */
}
}js
function applyTheme(choice) {
// choice: 'light' | 'dark' | 'system'
const root = document.documentElement
if (choice === 'system') {
root.removeAttribute('data-theme')
} else {
root.dataset.theme = choice
}
localStorage.setItem('theme', choice)
}暗色变量要在两个地方各写一遍(媒体查询和 data-theme)。用 Sass mixin 或构建工具生成,可以避免两边不同步。
3. 坑一:每次刷新,先闪一下白色
现象:已经选了暗色模式,但每次打开或刷新页面,都会先显示一下白色页面,然后才变暗,晚上特别刺眼。
原因:主题是在应用的 JS 加载、执行之后才设置的。在那之前,浏览器已经用默认的浅色样式把页面画出来了。
修复:在 <head> 里、样式表之前,放一小段同步执行的内联脚本,在第一次绘制前就设置好主题:
html
<head>
<script>
;(function () {
try {
var choice = localStorage.getItem('theme')
if (choice === 'light' || choice === 'dark') {
document.documentElement.dataset.theme = choice
}
} catch (e) {
// 隐私模式等情况下 localStorage 可能不可用,此时跟随系统即可
}
})()
</script>
<link rel="stylesheet" href="/assets/app.css" />
</head>这段脚本必须很小、不依赖任何库,也不能加 defer 或 async。服务端渲染的项目,可以把用户的选择存在 Cookie 里,服务端直接输出带 data-theme 的 HTML。
4. 坑二:滚动条、输入框、下拉框还是白的
现象:页面背景已经变暗,但浏览器的滚动条、<input>、<select>、日期选择器依然是浅色的。
原因:这些原生控件由浏览器绘制,它们并不知道你的页面是暗色主题。
修复:用 color-scheme 属性告诉浏览器当前使用哪种配色:
css
:root {
color-scheme: light dark; /* 支持两种,跟随系统 */
}
:root[data-theme='light'] { color-scheme: light; }
:root[data-theme='dark'] { color-scheme: dark; }浏览器会据此切换滚动条、表单控件和默认背景的配色,一行代码解决一大片问题。
5. 坑三:有些图标和组件就是不变色
现象:切换到暗色后,一些图标还是深色的,几乎看不见;某些按钮、表格背景保持原样。
原因:
- SVG 图标的
fill或stroke写死了颜色,比如fill="#333"; - 组件样式里直接写了颜色值,没有引用变量;
- 第三方组件库有自己的一套颜色变量。
修复:
- SVG 图标的颜色改为
currentColor,它会跟随父元素的文字颜色:
html
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<path fill="currentColor" d="M12 2 2 22h20L12 2z" />
</svg>- 搜索组件里写死的
#、rgb(颜色,替换为语义变量; - 第三方组件库通常提供暗色主题或 CSS 变量,按它的文档切换,并把它的变量映射到你自己的令牌上。
6. 坑四:暗色模式下文字看不清,或者太刺眼
现象:次要说明文字在暗背景上几乎看不见;或者纯白的正文在纯黑背景上,看久了眼睛很累。
原因:对比度太低看不清,对比度过高又会产生眩光。很多人在亮色模式下精心检查过对比度,暗色模式却是“凭感觉”调的。
修复:每个主题都要单独检查对比度。WCAG 建议正文与背景的对比度至少 4.5:1,大号文字至少 3:1。同时避免纯白配纯黑(对比度 21:1),使用浅灰文字配深灰背景。
在 DevTools 的 Styles 面板点击颜色色块,取色器里会直接显示当前文字与背景的对比度,并标出是否达标。
7. 坑五:阴影没了,卡片和背景糊成一片
现象:亮色模式下靠阴影浮起来的卡片,在暗色模式下和背景融为一体。
原因:阴影是“更暗的颜色”,投在本来就很暗的背景上几乎不可见。
修复:暗色模式下用表面亮度表达层级:越靠上(越“浮起来”)的元素,背景色越亮;必要时加一条低对比度的边框。
css
:root[data-theme='dark'] {
--color-surface: #17252b; /* 卡片 */
--color-surface-raised: #1f3038; /* 弹窗、下拉菜单,更亮一些 */
}8. 不要只靠颜色传达信息
“红色表示失败、绿色表示成功”,对红绿色弱的用户来说两者可能没有区别。暗色模式下,有些颜色的辨识度还会进一步下降。状态信息要同时配上图标或文字,比如“✓ 已保存”“✕ 保存失败”。
9. 切换时的过渡
切换主题时颜色瞬间跳变会有点突兀,可以给背景和文字颜色加一个短过渡:
css
body {
transition: background-color 200ms ease, color 200ms ease;
}不要写 * { transition: all 200ms }:它会让页面上所有属性的变化都带上过渡,包括布局变化,既卡又容易出现意外的动画。开启了“减少动态效果”的用户,应关闭这个过渡。
排查思路
- 模拟系统主题:DevTools → Ctrl/Cmd + Shift + P → 输入 “Rendering” → 在 Emulate CSS media feature prefers-color-scheme 里切换 light/dark,不用改系统设置。
- 找写死的颜色:在代码里搜索
#、rgb(、hsl(;在页面上逐个组件检查两种主题。 - 检查对比度:Styles 面板的取色器、Lighthouse 的无障碍检查,都会标出对比度不足的文字。
- 复现闪烁:在 Network 面板把网速调慢再刷新,主题闪烁会更明显。
检查清单
- [ ] 暗色主题单独设计颜色:深灰背景、浅灰文字、调整后的品牌色
- [ ] 支持“浅色 / 深色 / 跟随系统”,并记住用户选择
- [ ]
<head>内联脚本在首次绘制前设置主题 - [ ] 设置了
color-scheme,原生控件跟随主题 - [ ] 图标使用
currentColor,组件里没有写死的颜色 - [ ] 两种主题下正文对比度都不低于 4.5:1
- [ ] 暗色模式用表面亮度表达层级,状态信息不只靠颜色
延伸阅读
- CSS 变量与主题系统:语义令牌怎么分层
- 可访问性实战:对比度和色弱用户
- 动画与过渡:减少动态效果的设置