暗色模式不是把颜色反过来:对比度、透明度叠加与 Canvas 重绘
暗色模式不是把颜色反过来:对比度、透明度叠加与 Canvas 重绘
很多产品的暗色模式是这么做的:加一个 filter: invert(1) hue-rotate(180deg),或者把背景色从 #fff 换成 #000、文字从 #000 换成 #fff,然后宣布支持深色模式了。
上线之后反馈会集中在三类:某些按钮颜色变脏、图片变成负片、文字在深色背景上"发毛"甚至看着比亮色模式更累。这些都不是调色不够用心——是反色这个思路本身不成立。
一、为什么"反色"在原理上就不对
颜色在界面里承载的是语义,不是数值。主色蓝 #0A59F7 反色后变成橙色,它在用户心里"可点击/主操作"的含义就消失了;红色的错误提示反成青绿,警示感也一起没了。
正确的做法是维护两套语义色映射,同一个 token 在不同主题下指向不同的具体颜色:
:root {
--bg-page: #ffffff;
--bg-card: #ffffff;
--text-primary: #1a1a1a;
--text-secondary: #666666;
--brand: #0a59f7;
}
[data-theme="dark"] {
--bg-page: #121212;
--bg-card: #1e1e1e;
--text-primary: #e8e8e8;
--text-secondary: #a0a0a0;
--brand: #6b9bff; /* 深色下把主色提亮,而不是反色 */
}
注意 --brand 在深色下不是反色,而是同色相提高明度——保证它在深背景上依然醒目、依然"是蓝的"。
二、第一个坑:透明度叠加在深色下会累积变脏
亮色模式里常用 rgba(0,0,0,0.05) 做浅灰分割线、rgba(255,255,255,0.6) 做半透明卡片。切到深色模式后,这些值如果只是简单调换,就会出现一个隐蔽问题:半透明层叠加是乘法关系,层数一多颜色就漂移。
举例:在深色背景上叠三层 rgba(255,255,255,0.08),结果亮度不是"8%+8%+8%",而是逐层合成后明显偏亮,看起来发灰、发脏。列表项 hover + 选中 + 禁用三种状态叠在一起时尤其明显。
两个可行做法:
- 用预计算的实色代替半透明,每个状态给一个确定的颜色值,不靠叠加;
- 保留半透明,但限制叠加层数,并且把最上层的不透明度调低——深色背景上的白色叠加,视觉增益比亮色背景上的黑色叠加更"跳"。
三、第二个坑:深色模式下的对比度要求反而更高
直觉上会觉得深色模式看着"柔和",但对小字、细体来说恰恰相反:深色背景上的浅色文字更容易出现光晕(halation),边缘发虚,阅读时需要更多注意力。
所以深色模式的对比度不能照着亮色模式等比例下降。经验上要保证:
- 正文文字与背景对比度 ≥ 4.5:1(小字)
- 次要文字也别低于 3:1,很多实现把它压到 2:1 左右,看起来"高级",实际很难读
- 不要用纯白
#fff文字配纯黑#000背景。这种极端对比会放大光晕,长时间阅读很累。常见做法是文字#e8e8e8左右、背景#121212左右
细体(font-weight: 300)在深色模式下尤其吃亏,必要时该在深色主题里把它提到 400。
四、第三个坑:Canvas、图片、图表不会自己跟着变
CSS 变量能让 DOM 元素换色,但画在 Canvas 里的东西不会自动重绘。图表、代码高亮、签名板、头像占位图都可能有这个问题。
必须显式监听主题变化并重绘:
const mq = matchMedia('(prefers-color-scheme: dark)');
function themeChanged() {
const isDark = mq.matches || document.documentElement.dataset.theme === 'dark';
drawChart({ dark: isDark }); // 重绘:背景、网格线、文字颜色都要按主题切换
}
mq.addEventListener('change', themeChanged);
new MutationObserver(themeChanged).observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme'],
});
图片同理:logo、插画通常需要准备深色版本,或者用 CSS 加一个半透明的底衬垫住,而不是直接反色。照片类图片永远不要反色,反色后的照片看着像故障。
五、两个容易被忽略的细节
阴影在深色下几乎等于不存在。 box-shadow: 0 2px 8px rgba(0,0,0,0.1) 放在 #121212 上肉眼看不出来——深色背景本来就暗,暗阴影没有对比。深色模式里表达层级,更可靠的是用更亮的背景色分层(#121212 → #1e1e1e → #262626),必要时再加一条极细的亮边(1px solid rgba(255,255,255,0.08))。
首屏闪白。 主题是 JS 读出来的,页面在脚本执行前会先用默认亮色渲染一帧,于是深色模式下打开页面会白闪一下。修法是在 <head> 里内联一小段脚本,在首次绘制前就把 data-theme 写到 html 上:
<script>
try {
var t = localStorage.getItem('theme')
|| (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.dataset.theme = t;
} catch (e) {}
</script>
这段必须内联、必须放在样式表之前,否则起不到作用。
六、自测清单
- 主题切换是否靠语义 token,而不是反色/滤镜
- 主色在深色下是否提亮而非反色,语义是否保留
- 半透明层叠加后,列表的 hover/选中/禁用三态是否还分得清、颜色是否发脏
- 正文对比度是否 ≥ 4.5:1,是否避开了纯白配纯黑
- 细体字在深色下是否已提粗
- Canvas/图表是否监听主题变化并重绘
- 图片是否用了深色专版,照片是否没有被反色
- 层级是否改用背景亮度和细边表达,而不是依赖阴影
- 首屏是否有白闪,
data-theme是否在首次绘制前写入
一句话总结
暗色模式不是一次颜色计算,而是把"颜色"重新拆成"语义 + 每个主题下的一组取值"。反色能过验收,但过不了长期使用——真正让深色模式好用的,是对比度、叠加层数和重绘时机这三件琐碎事。
- 点赞
- 收藏
- 关注作者
评论(0)