新闻 本站已迁移到 Astro + Fuwari 主题 文章 全站共 5 篇原创,持续更新中 浏览 欢迎在文章下方留言交流 RSS 订阅 /rss.xml 获取更新 关于 前端工程 / 设计系统 / 随笔 提示 评论由 Waline 驱动,数据存放在自己的数据库 新闻 本站已迁移到 Astro + Fuwari 主题 文章 全站共 5 篇原创,持续更新中 浏览 欢迎在文章下方留言交流 RSS 订阅 /rss.xml 获取更新 关于 前端工程 / 设计系统 / 随笔 提示 评论由 Waline 驱动,数据存放在自己的数据库
1373 字
7 分钟
用 CSS 变量构建可切换主题的设计系统

第一次做暗色模式时,我的做法是在 [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);
}

注意这里有两个不对称:

  1. 暗色的背景不是纯黑,亮色的文字不是纯黑。纯黑和纯白在屏幕上会产生过强的边缘对比,长时间阅读会累。
  2. 强调色在暗色下要变亮。琥珀色 #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);
}

小结#

如果只能记住一句话:让组件认识角色,让主题决定颜色。

三层结构听起来麻烦,但它把”改一个颜色”从”全局搜索替换”变成了”改一行变量”。这件事在项目第六个月的价值,远高于第一周多花的两小时。

用 CSS 变量构建可切换主题的设计系统
https://lumen-blog-3iq.pages.dev/posts/design-tokens-with-css-variables/
作者
Lumen
发布于
2026-03-04
许可协议
CC BY-NC-SA 4.0
评论

评论还没接上

在项目根目录的 .env 里加上 PUBLIC_WALINE_SERVER_URL=你的 Waline 地址,重新构建即可启用。

怎么部署 Waline
目录
1
为什么直接写颜色会失控
2
三层令牌结构
第一层:原始值
第二层:语义别名
第三层:组件令牌
3
在 Tailwind 里接上它
4
暗色模式不是”反色”
5
三个容易忽略的细节
prefers-reduced-transparency
对比度要在两套主题里分别测
过渡的时机
6
小结
站点统计
文章 5
分类 3
标签 7
总字数 4949
最后活动 03/04
运行天数 1032 天
欢迎来到Lumen!

这里记录前端工程、设计系统,以及一些与技术无关的观察。慢慢看,不着急。