第一次做暗色模式时,我的做法是在 [data-theme="dark"] 里把颜色全部反一遍。上线那天看起来很酷,三个月后,代码里出现了十七个 #1a1a1a 的不同变体,没人说得清哪个才是”卡片背景”。
问题不在暗色模式,在于我们把颜色值当成了决策。
为什么直接写颜色会失控
一个颜色在界面上承担的角色,往往比它本身更重要。同样是灰色,在导航栏上是”次要文字”,在卡片里是”边框”,在按钮上是”禁用态”。
如果代码里只有 #78716c,这三个角色就被压成了同一个东西。等到暗色模式需要不同的对比度、或者品牌色调整,你得逐个去找——而人眼在深色背景上对对比度的判断,比浅色背景差得多。
所以第一件事是给角色命名,而不是给颜色命名。
三层令牌结构
我现在用的是三层:原始值 → 语义别名 → 组件令牌。大部分项目做到第二层就够了。
第一层:原始值
只描述”是什么颜色”,不描述”用在哪”。这一层几乎不直接被组件引用。
:root { --stone-50: #fafaf9; --stone-900: #1c1917; --amber-700: #b45309; --amber-400: #fbbf24;}用现成的色阶(Tailwind 的 stone / amber 就很好)比自己调更有纪律性,因为它们的明度是均匀的。
第二层:语义别名
这一层才是主题切换的开关。组件只认这些名字。
:root { --bg: var(--stone-50); --text: var(--stone-900); --accent: var(--amber-700); --line: rgba(28, 25, 23, 0.09);}
[data-theme="dark"] { --bg: #0c0a09; --text: #e7e5e4; --accent: var(--amber-400); --line: rgba(255, 255, 255, 0.09);}注意这里有两个不对称:
- 暗色的背景不是纯黑,亮色的文字不是纯黑。纯黑和纯白在屏幕上会产生过强的边缘对比,长时间阅读会累。
- 强调色在暗色下要变亮。琥珀色
#b45309放在深色背景上会糊成一团,换成#fbbf24才有足够的亮度。
这两点几乎是所有”反色方案”翻车的地方。
第三层:组件令牌
只有真正复杂的组件才需要,比如玻璃面板:
:root { --glass-bg: rgba(255, 255, 255, 0.55); --glass-border: rgba(255, 255, 255, 0.6); --glass-shadow: 0 1px 2px rgba(28, 25, 23, 0.04), 0 10px 30px -12px rgba(28, 25, 23, 0.16);}玻璃的四个参数(底色、边框、阴影、模糊半径)在亮暗两套主题里必须一起调整。分开写,必然出现”背景对了但边框刺眼”的情况。
| 层级 | 命名方式 | 谁在用 | 改动频率 |
|---|---|---|---|
| 原始值 | --stone-900 | 几乎没人 | 极低 |
| 语义别名 | --text、--bg | 所有组件 | 中 |
| 组件令牌 | --glass-bg | 单一组件 | 高 |
在 Tailwind 里接上它
Tailwind v4 用 CSS-first 配置,可以直接把语义变量映射成工具类:
@theme inline { --color-paper: var(--bg); --color-ink: var(--text); --color-muted: var(--text-muted); --color-accent: var(--accent);}关键在 inline。它让 Tailwind 生成 color: var(--text) 而不是 color: #1c1917——前者会跟随主题变化,后者不会。
于是你可以照常写 text-ink、bg-paper、border-line,而主题切换是透明的。
如果你的 Tailwind 还是 v3,等价做法是在
tailwind.config.mjs的colors里写ink: 'var(--text)'。效果一样,只是少了一点类型提示。
暗色模式不是”反色”
这是我最想强调的一点。反色是一个数学操作,而暗色模式是一个设计问题。
几个具体的差异:
- 亮色下的阴影靠”暗”来体现层次,暗色下阴影几乎不可见,得靠更亮的边框或者轻微的背景提亮来分层。
- 亮色下 1px 的
rgba(0,0,0,0.09)边框刚刚好,暗色下同样的透明度会完全消失,通常需要提高到0.12–0.16。 - 大面积的纯色区块在暗色下会显得比实际更大,间距需要相应收紧一点点。
所以我的做法是:暗色不是一套派生值,而是一套独立的设计,只是共享同一组变量名。
三个容易忽略的细节
prefers-reduced-transparency
玻璃拟态依赖 backdrop-filter,但对一部分用户来说,半透明背景会让文字难以辨认。系统提供了偏好查询:
@media (prefers-reduced-transparency: reduce) { .glass { backdrop-filter: none; background-color: var(--surface-solid); border-color: var(--line-strong); }}这不是可选项。玻璃效果越重,这个降级越重要。
对比度要在两套主题里分别测
同一个 --text-muted,在 #fafaf9 上可能是 7.5:1,在 #0c0a09 上可能是 8:1——看起来都不错。但如果你的暗色背景偏蓝,实际感知对比度会低不少。
我习惯用 4.5:1 作为正文下限,3:1 作为大字号下限,然后在两套主题下各测一遍。
过渡的时机
主题切换加 transition: background-color 200ms 很常见,但会有一个副作用:页面初次加载时,浏览器可能对初始值也做一次过渡,出现一闪而过的中间色。
解法是只在切换的瞬间加过渡类,或者干脆用 View Transitions 做圆形揭示——后者还能顺便解决”变化太突兀”的问题。
if (document.startViewTransition && !reduceMotion) { document.startViewTransition(() => applyTheme(next));} else { applyTheme(next);}小结
如果只能记住一句话:让组件认识角色,让主题决定颜色。
三层结构听起来麻烦,但它把”改一个颜色”从”全局搜索替换”变成了”改一行变量”。这件事在项目第六个月的价值,远高于第一周多花的两小时。
评论还没接上
在项目根目录的
怎么部署 Waline.env里加上PUBLIC_WALINE_SERVER_URL=你的 Waline 地址,重新构建即可启用。