深色模式现在算是基础配置了。实现方式有好几种,我最后固定下来的是「CSS 自定义属性 + 语义化命名」这一套,改一次变量整站生效,不用在每个组件里写两遍颜色。记一下具体做法和几个坑。
先定义语义,再定义颜色
最容易犯的错是把变量命名成颜色本身:
/* ❌ 深色模式下这些名字全是谎言 */
:root {
--white: #ffffff;
--gray-800: #333333;
}
深色模式下 --white 得变成近黑色,名字就彻底失去意义了。正确的做法是按用途命名——这个颜色是干什么的,而不是它长什么样:
:root {
--bg: #fbfaf8; /* 页面底色 */
--panel: #ffffff; /* 卡片、浮层的表面 */
--ink: #1c1b19; /* 主文字 */
--ink-2: #55524d; /* 次级文字 */
--ink-3: #8a857e; /* 辅助信息、占位 */
--line: #e7e3dd; /* 分隔线、边框 */
--accent: #b1553a; /* 强调色 */
}
这样切换主题时只需要重新赋值,组件里的 color: var(--ink-2) 一个字都不用改。层级命名(ink / ink-2 / ink-3)也顺带把文字对比度的层次固定了下来,写页面时不会随手挑一个中间灰。
跟随系统
最简单的一档是只跟随系统设置:
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f0e0d;
--panel: #191817;
--ink: #f0ede8;
--ink-2: #a9a49c;
--ink-3: #6f6a63;
--line: #282624;
--accent: #e08258;
}
}
纯静态站点用这一档就够了,零 JS。别忘了同时声明:
:root { color-scheme: light dark; }
它会让浏览器把表单控件、滚动条、默认背景一起切成深色。少了这一行,页面本身是深色的,但滚动条还是亮白的一条,很出戏。
加上手动切换
想让用户能覆盖系统设置,就需要三种状态:跟随系统、强制浅色、强制深色。用一个 data-theme 属性来表达:
/* 1. 浅色作为基准,写在裸 :root 上 */
:root { --bg: #fbfaf8; --ink: #1c1b19; /* … */ }
/* 2. 系统深色,但排除掉用户强制浅色的情况 */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #0f0e0d; --ink: #f0ede8; /* … */
}
}
/* 3. 用户强制深色,优先级最高 */
:root[data-theme="dark"] {
--bg: #0f0e0d; --ink: #f0ede8; /* … */
}
第 2 条里的 :not([data-theme="light"]) 是关键。少了它,系统是深色、用户手动选浅色时,两条规则会打架。
深色的值写了两遍,看着冗余,但这是让三种状态都正确的最短写法。想去重可以把深色变量抽成一个 @mixin(预处理器)或者用 light-dark() 函数——后者现在浏览器支持已经不错了:
:root {
color-scheme: light dark;
--bg: light-dark(#fbfaf8, #0f0e0d);
--ink: light-dark(#1c1b19, #f0ede8);
}
light-dark() 依赖 color-scheme 生效,手动切换时改 color-scheme: light 或 dark 即可,写法干净很多。
刷新闪白
如果主题偏好存在 localStorage,而读取它的脚本放在页面底部或用了 defer,就会出现经典的一闪:浏览器先按默认样式画了一帧浅色,脚本才把属性改成 dark。
解决办法是在 <head> 里放一段同步执行的内联脚本,赶在首次绘制之前把属性写上:
<script>
try {
var t = localStorage.getItem('theme');
if (t) document.documentElement.dataset.theme = t;
} catch (e) {}
</script>
这段必须内联、必须同步、必须在 head 里,三个条件缺一不可。外链脚本会引入网络往返,defer 会推到解析之后,都来不及。try/catch 是因为隐私模式下访问 localStorage 可能直接抛异常。
切换时别让整页做过渡
如果全站给颜色加了 transition,点一下主题切换按钮,页面上每个元素都会同时开始一段颜色补间,看起来像糊了一层。做法是切换的瞬间临时禁用过渡:
function setTheme(next) {
var root = document.documentElement;
root.classList.add('no-transition');
root.dataset.theme = next;
// 强制一次样式计算,确保新颜色已应用
void root.offsetWidth;
root.classList.remove('no-transition');
}
.no-transition, .no-transition * {
transition: none !important;
}
中间那句 void root.offsetWidth 是在强制同步刷新样式,少了它浏览器会把两次 class 改动合并,等于什么都没做。
深色不是把颜色反过来
几个实际调下来的经验:
- 别用纯黑。
#000配纯白文字对比度过高,长时间看很累,还容易在 OLED 屏上出现拖影。用#0f0e0d这种带一点色相的深色更舒服。 - 深色下降低饱和度。同一个强调色在深色背景上会显得更刺眼,通常要调亮一点、同时把饱和度压下来。浅色的
#b1553a在深色下我改成了#e08258。 - 阴影在深色下几乎不可见。浅色模式靠阴影区分层级,深色模式要改用「更亮的表面色」来表达抬升——卡片比页面底色亮一档,浮层再亮一档。
- 图片和图标要单独照顾。深色背景上的白底 PNG 会变成一块刺眼的方块。SVG 图标尽量用
currentColor,位图考虑加一点filter: brightness(.9)或者准备两套。
顺手检查一下对比度
WCAG 的门槛是正文 4.5:1、大号文字 3:1。辅助文字(我这里的 --ink-3)最容易踩线,因为设计上就想让它「淡一点」。深浅两套都要各测一遍——浅色下达标的灰,深色下同样的灰度差可能就不够了。DevTools 的取色器会直接标出对比度和是否通过,改的时候顺手看一眼就行。