暗色切换时的那一下闪白
主题切换的难点不在「换颜色」,而在「首屏不能闪」和「token 要能一套语义两套值」。
1. 跟随系统、手动切换、两者并存
1.1 class / 属性切换
在 <html> 上挂 data-theme="dark",CSS 变量随之覆盖,最常用。
1.2 跟随系统
用 prefers-color-scheme 媒体查询,用户改系统设置即变。
1.3 多份 CSS 文件
彻底隔离但产物体积翻倍,几乎不再推荐。
实际项目一般支持「浅色 / 深色 / 跟随系统」三态,跟随系统需要监听 matchMedia 的变化。先确定要支持几态,再选方案。
2. 首屏闪白是怎么来的
闪烁的根因是「先按默认主题渲染,JS 执行后才改主题」。三种做法按代价从低到高:
- SSR 把主题写在
<html>属性上:服务端输出时就定好,第一帧即正确,最稳; - head 内联同步脚本:读
localStorage与系统偏好后立即打属性,脚本必须内联且不能异步; - 纯 CSS 跟随系统:用
prefers-color-scheme兜底,但手动切换后要覆盖,会有一次跳变。
最差的做法是等 JS 拉完用户配置再决定主题,用户一定会看到一次白闪。
<script>
(function () {
try {
var saved = localStorage.getItem('theme')
var sysDark = window.matchMedia('(prefers-color-scheme: dark)').matches
var theme = saved === 'light' || saved === 'dark'
? saved
: (sysDark ? 'dark' : 'light')
document.documentElement.dataset.theme = theme
} catch (e) {}
})()
</script>
3. 让 token 承担两套值
深色不是把浅色的值取反。层级在浅色里靠阴影表达,在深色里必须改成靠表面亮度——越靠上的层越亮;饱和度也要整体降一档,纯白纯黑要避开(用 #121212–#1e1e1e 这类深灰更护眼)。这也是为什么主题必须建立在语义 token 上(如 color-surface-1),而不是让组件直接引用 gray-900:前者换的是映射关系,后者换的是每个组件。
:root {
color-scheme: light dark; /* 让滚动条/表单控件/默认背景跟随 */
}
:root[data-theme="dark"] {
color-scheme: dark;
--color-surface-1: #1e1e1e;
--color-text: #e5e7eb;
}
:root[data-theme="light"] {
--color-surface-1: #ffffff;
--color-text: #111827;
}
.card { background: var(--color-surface-1); color: var(--color-text); }
如果主题只有「浅/深」两态、不需要多套语义,也可以用 light-dark() 把两个值写在同一行,省掉整块 @media:声明 color-scheme: light dark 之后,浏览器按系统偏好或 data-theme 自动取用第二个值。
:root { color-scheme: light dark; }
.card {
/* 第一参数是浅色值,第二参数是深色值 */
background: light-dark(#ffffff, #1e1e1e);
color: light-dark(#111827, #e5e7eb);
}
颜色本身更推荐用 oklch() 而非 hex:它在感知上更均匀,调整「明度」分量就能让深色层的表面逐级变亮(oklch(0.12) → 0.18 → 0.24),也方便统一降饱和度;而 light-dark() 只响应 color-scheme,所以漏掉 color-scheme 时它会一直取浅色值,是个常见坑。
漏掉 color-scheme 是「深色做得不干净」最常见的原因:滚动条、表单控件、默认背景仍是浅色,与整页格格不入。
3.1 切换时浏览器要重算什么
切换根级 CSS 变量会触发受影响元素的样式重算。变量定义在较高层级很方便,但数量失控时,一次切换会重算大量元素,带来可感知的卡顿。控制变量规模的建议:语义 token 收敛到几十个、把会一起变的变量归并、避免给成千上万个节点各挂独立变量。
「跟随系统」这一档要能实时响应,用户在系统里切了主题页面也得跟着走:
const mq = window.matchMedia('(prefers-color-scheme: dark)')
// 只有选了「跟随系统」才挂这个监听,否则会覆盖用户的手动选择
if (localStorage.theme === 'auto') {
mq.addEventListener('change', (e) => applyTheme(e.matches ? 'dark' : 'light'))
}
4. 容易漏掉的几处
4.1 图片与图表
深色下要降透明度或加滤镜,避免亮底图在深色背景上刺眼;图表配色要单独出一套深色版。
4.2 meta theme-color
浏览器地址栏颜色要同步,否则深色页面配浅色地址栏很突兀。
4.3 表单控件
靠 color-scheme 自动适配,不必逐个写。
prefers-reduced-transparency:部分系统支持降低透明效果,深色 + 毛玻璃叠加时要给不透明的回退,否则对比度会塌。
4.4 forced-colors 模式
用户在系统里开启高对比后,作者颜色会被替换成系统调色板,状态不能只靠颜色表达(呼应无障碍那篇),否则深色做得再好也会在这种模式下失效。
<meta name="theme-color" content="#0f0f23" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
MDN:color-scheme 这篇说明了为什么表单控件和滚动条会跟着系统走,做暗色适配时值得先看一遍。