CSS 变量做深色模式:三种用法,与一次 38% 的颜色泄漏审计
CSS 变量做深色模式:三种用法,与一次 38% 的颜色泄漏审计
上周我把一份自用的评估报告样式从"只有浅色"改成"浅色 + 深色"。这份样式只有 3183 字节,看起来是最容易改的那类文件,结果它把 CSS 变量做主题时最典型的几个位置问题一次性暴露了。下面按"跟随系统 → 手动开关 → 局部孤岛"三层展开,每一层给出可直接用的写法,也给出我实际踩到的坑。文中所有对比度数字都是按 WCAG 相对亮度公式算出来的实测值,不是估计。
一、只有媒体查询,等于把控制权完全交给操作系统
最省事的写法是把两套调色板塞进同一个 :root,用媒体查询切换:
:root{
color-scheme: light;
--bg:#ffffff; --fg:#1f2328; --muted:#59636e; --line:#d8dee4;
}
@media (prefers-color-scheme: dark){
:root{
color-scheme: dark;
--bg:#0d1117; --fg:#e6edf3; --muted:#8b949e; --line:#30363d;
}
}
color-scheme 这一行不要省。它不是给 CSS 用的,是告诉浏览器"本页属于哪类配色":滚动条、<input>、<select> 的原生控件、<textarea> 的插入符、自动填充高亮都由它决定。我第一次改完忘写,深色页面里所有输入框仍然是白底黑字,视觉上像贴了一块补丁。补上 color-scheme: dark 后这些控件自己就变深了——一行属性的问题,不知道有这个属性的人会去调 background 调半天。
这个方案的天花板在于它没有"开关"。prefers-color-scheme 的取值只由操作系统或浏览器设置决定,页面 CSS 无法改写它。你可以适配、可以监听,但没法让用户在页面上选。要提供切换按钮,这一层就必须让位给第二层。
二、用 data-theme 做开关,特异性与首屏闪烁是两个独立问题
html[data-theme="dark"]{
color-scheme: dark;
--bg:#0d1117; --fg:#e6edf3; --line:#30363d; --soft:#161b22;
}
html[data-theme="light"]{
color-scheme: light;
--bg:#ffffff; --fg:#1f2328; --line:#d8dee4; --soft:#f6f8fa;
}
选择器写在 html 上而不是 body 上,理由是背景传播:html 的背景会铺满整个画布,body 的不会。把 data-theme 挂在 body 上,深色模式下向上或向下越界滚动(overscroll)时,html 仍是默认白色,会露出一道白边。
特异性这点值得单独记一下::root 是伪类,特异性 (0,1,0);html[data-theme="dark"] 是元素加属性,特异性 (0,1,1),后者胜出。所以不需要 !important,也不依赖书写顺序。很多人的"深色覆盖不生效"其实是把选择器写成了 .dark(同样是 0,1,0)与 :root 平级,然后靠先后顺序碰运气。
首屏闪烁是另一个问题,与特异性无关。如果主题在 DOMContentLoaded 之后才设置,浏览器已经用默认主题画过一帧,深色用户会看到一次白闪。修法是把判断放进 <head> 里的同步脚本,在首次绘制前把属性落到文档元素上:
<script>
try{
var t = localStorage.getItem('theme');
if(!t) t = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
document.documentElement.dataset.theme = t;
}catch(e){ document.documentElement.dataset.theme = 'light'; }
</script>
try/catch 不是写着好看。在部分隐私模式、或者用 file:// 直接打开页面时,访问 localStorage 会抛 SecurityError,一旦抛出,后面那行 dataset.theme 就不会执行,整页退回浅色。现象是"深色模式在这台机器上完全失效",而样式表里一个错都没有。我在本地用 file:// 打开报告时就遇到过一次,排查时一直在怀疑选择器优先级,实际是存储访问抛异常把脚本整段中断了。
三、把变量重定义在容器上,得到"主题孤岛"
第三种用法最少被提到,但在报告类文档里最实用:正文跟随浅色,代码块和终端输出始终保持深色。
pre, .terminal{
--bg:#0d1117; --fg:#e6edf3; --line:#30363d;
background:var(--bg); color:var(--fg);
border:1px solid var(--line);
}
关键在于内部元素一条规则都不用改。自定义属性沿 DOM 继承,pre 里面任何 background:var(--bg) 都会自动解析到容器上重定义的值。这正是 CSS 变量与预处理器变量的实质差别:Sass 变量在编译期就被替换成字面量,作用域只是源码组织手段;CSS 自定义属性是运行期的继承值,可以按 DOM 位置切换。
这里的第一个坑是继承穿透。如果孤岛里写了 a{color:var(--brand)},而 --brand 没有在孤岛上重定义,查找会继续向上命中 :root 的浅色品牌蓝 #0969da——这个颜色放在 #0d1117 上只有 3.64:1,肉眼看上去像"链接没变成深色"。规则很直接:孤岛要重定义它用到的每一个变量。
第二个坑是过渡。未注册的自定义属性不能直接参与 transition,transition:--bg .3s 是无效的:注册之前它按 token 流处理,没有可插值的类型。能过渡的是消费它的属性,因为 background-color 的计算值是一个颜色,所以 transition:background-color .3s 有效。确实需要变量本身可过渡时,用 @property 补齐类型:
@property --bg{ syntax:'<color>'; inherits:true; initial-value:#ffffff; }
变量层不等于主题层:一次 38% 的泄漏审计
改完我写了个脚本扫这份样式,结果不太好看:
- 文件 3183 字节,
--token声明 13 条,var(--...)引用 27 处; - 颜色字面量 21 个,其中 13 个在
:root里,8 个散落在规则体中,占 38.1%; - 泄漏集中在 5 个评级徽章类(
.g-A到.g-E)和.tag,底色写死为#1a7f37、#0969da、#9a6700、#bc4c00、#cf222e、#6e7781。
也就是说,我把正文、边框、底色都接进了变量层,却把最显眼的彩色小块留在了外面。这种"半接入"比完全不接入更难发现:深色模式整体看着是对的,直到出现一个评级徽章——绿底白字在深色页面上依旧扎眼,因为它压根没被主题覆盖。
顺手还验了一件事:用通道取反(每个分量 255 - c)批量生成深色是否可行。算出来的对比度反而更高:危险色 #cf222e 从 5.36 升到 12.39,评级绿从 5.08 升到 8.25,全部远超 4.5:1。但颜色本身变了味——绿 #1a7f37 取反成 #e580c8(粉),警告黄 #9a6700 取反成 #6598ff(蓝),危险红 #cf222e 取反成 #30ddd1(青)。对比度全部合格,语义全部错位:一个"通过"标记变成粉色,一个"警告"变成蓝色。
还有一条更容易忽略的:把浅色调色板原样搬到深底,几乎必然不及格。同一批 token 放到 #0d1117 上的实测值如下。
| 用途 | 浅色值 | 放在深底上的对比度 |
|---|---|---|
| 正文 | #1f2328 |
1.20 |
| 次要文字 | #59636e |
3.10 |
| 成功 | #1a7f37 |
3.73 |
| 警告 | #9a6700 |
3.89 |
| 危险 | #cf222e |
3.53 |
| 链接 | #0969da |
3.64 |
六项全部低于 4.5:1,正文只有 1.20——基本等于看不见。深色语义色必须按暗底重新取值。重新选的一组在 #0d1117 上实测:正文 #e6edf3 16.02、次要 #8b949e 6.15、成功 #3fb950 7.45、警告 #d29922 7.50、危险 #f85149 5.65、链接 #58a6ff 7.49。
结论
CSS 变量把主题切换简化成了变量重定义,这部分确实便宜;真正花时间的是另外两件事——找出哪些颜色根本没进变量层,以及判断哪些语义色不能机械换算。前者可以用脚本扫字面量分布查出来,后者只能对着暗底一个个重配、逐个核对比度。媒体查询、data-theme、容器级孤岛这三层解决的是三个不同的问题,放在同一份样式里不会互相冲突,前提是每一层都清楚自己负责哪一段 DOM。
- 点赞
- 收藏
- 关注作者
评论(0)