全部文章

用 CSS 变量构建可切换主题的设计系统

从原始令牌到语义令牌的三层结构,以及为什么大多数暗色模式会在上线三个月后开始崩坏。

本文目录12 节

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

小结

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

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

评论

说点什么,或者只是路过留个脚印。

评论系统尚未连接

本项目使用 Waline。 在项目根目录创建 .env,填入你的服务端地址后重新构建即可启用:

PUBLIC_WALINE_SERVER_URL=https://your-waline.vercel.app