设计系统和 Design Token:别让颜色和间距散落在业务代码里

设计系统听起来很大,但很多团队真正需要解决的第一件事很朴素:颜色、字号、间距不要散落在业务代码里。

我见过一个项目里同时存在 #1677ff#1890ffrgb(22, 119, 255)var(--blue-6),它们看起来都是主色,但谁也说不清哪个是标准。设计一改品牌色,前端全项目搜索替换,改完还漏。

Design Token 的价值就在这里。它把设计决策抽成可命名、可复用、可转换的变量,让设计和代码之间有一层稳定契约。

Token 不是变量名越多越好

最基础的 token 可能长这样:

1:root {
2  --color-blue-500: #1677ff;
3  --color-gray-100: #f5f5f5;
4  --space-4: 16px;
5  --radius-2: 8px;
6}

这些属于基础 token,描述的是原始值。

但业务里直接使用基础 token 仍然不够。因为一个按钮的背景色不应该叫“blue-500”,而应该叫“primary”。否则哪天主色从蓝色改成绿色,--color-blue-500 这个名字就很尴尬。

所以我更喜欢分两层:

1:root {
2  --color-blue-500: #1677ff;
3  --color-white: #ffffff;
4
5  --color-brand-primary: var(--color-blue-500);
6  --button-primary-bg: var(--color-brand-primary);
7  --button-primary-text: var(--color-white);
8}

基础 token 负责值,语义 token 负责用途。业务代码尽量用语义 token。

组件 token 解决的是局部变化

全局 token 不能解决所有问题。

一个按钮可能有高度、圆角、背景、hover、disabled、loading 等状态。如果业务页面直接写一堆全局变量,很快也会乱。

组件内部可以再定义组件 token:

1.button {
2  --button-height: 36px;
3  --button-padding-x: 16px;
4  --button-radius: var(--radius-2);
5
6  height: var(--button-height);
7  padding: 0 var(--button-padding-x);
8  border-radius: var(--button-radius);
9}

这样组件的可变点集中在组件边界内。以后某个场景需要紧凑按钮,可以覆盖组件 token:

1.toolbar {
2  --button-height: 28px;
3  --button-padding-x: 10px;
4}

这比到处写 .toolbar .button { height: 28px } 更清楚。

不要让业务绕过系统

设计系统最怕“临时写一下”。

1.card-title {
2  color: #333;
3  margin-bottom: 13px;
4}

一次两次没问题,半年后就会堆成视觉债务。间距 12、13、14、15 都有,颜色差一点点,页面看起来就是不整齐。

我现在会把业务样式分成两类:

  • 布局样式:当前页面自己决定
  • 视觉样式:优先使用 token 或组件变体

比如一个详情页的两栏布局可以自己写,但按钮颜色、表单高度、卡片圆角、文字层级应该来自系统。

这不是限制业务,而是减少无意义选择。业务开发不应该每次都重新决定“次级文字到底是 #666 还是 #667085”。

主题切换要从 token 层做

深色模式如果靠给每个组件写一份 dark 样式,会很痛苦。

更合理的是切换 token:

1:root {
2  --page-bg: #ffffff;
3  --text-primary: #1f2329;
4  --border-subtle: #e5e6eb;
5}
6
7[data-theme='dark'] {
8  --page-bg: #111318;
9  --text-primary: #f2f3f5;
10  --border-subtle: #2f333d;
11}

组件只消费语义变量:

1.panel {
2  background: var(--page-bg);
3  color: var(--text-primary);
4  border: 1px solid var(--border-subtle);
5}

组件不关心当前是亮色还是暗色。主题变化时,变量值变了,组件自然跟着变。

这里要注意,不是所有颜色都能简单反转。阴影、边框、禁用态、hover 态在深色模式下都要重新设计。token 只是承载结果,不会替你做视觉判断。

主题也不止“亮/暗”一个维度。品牌换肤、紧凑/宽松密度、高对比模式,本质上都是切换同一批语义 token 的取值。用 data-* 属性把维度拆开,比堆一堆 class 更清楚:

1:root { --space-unit: 8px; }
2[data-density='compact'] { --space-unit: 6px; }
3[data-brand='festival'] { --color-brand-primary: #e8412b; }

组件永远只消费 --color-brand-primary--space-unit,至于当前是哪个品牌、哪种密度,它不关心。维度之间还能自由组合——暗色 + 紧凑 + 节日皮肤同时生效,靠的就是这些属性各管一层,互不打架。

有个 2024 年可以谨慎用起来的新工具是 color-mix(),它让一部分派生色不用手写死值。比如 hover 态想在主色上叠一点黑:

1.button:hover {
2  background: color-mix(in srgb, var(--color-brand-primary) 90%, black);
3}

主色一改,hover 色自动跟着算,不用再单独维护一个 --button-primary-hover。Chrome、Safari 都已支持,我在内部项目先用起来了;但它毕竟还新,对外的生产项目我会留一个写死的 fallback 兜底,别一上来就全站依赖。

Token 要能被工具消费

如果设计 token 只存在 CSS 里,设计工具、移动端、文档站可能用不上。更完整的方式是用结构化数据维护源头,再生成不同平台需要的产物。

比如:

1{
2  "color": {
3    "brand": {
4      "primary": { "value": "#1677ff" }
5    }
6  },
7  "space": {
8    "4": { "value": "16px" }
9  }
10}

然后生成:

  • CSS variables
  • JS theme object
  • TypeScript 类型
  • 设计文档
  • 移动端资源

小团队不一定一开始就要上完整工具链,但至少要保证 token 有一个权威来源。最怕设计稿一套、CSS 一套、组件库又一套。

命名比想象中重要

Token 命名要稳定。

我不喜欢在业务 token 里写具体颜色名:

1--text-gray
2--button-blue

更推荐写用途:

1--text-secondary
2--button-primary-bg

用途比颜色更稳定。今天 secondary text 是灰色,明天也许带一点蓝,但它仍然是 secondary text。

当然,基础色阶可以有颜色名,因为它描述的是原始调色板:

1--color-gray-500
2--color-blue-600

关键是不要让业务组件直接依赖太多基础色阶。

字号和间距 token 尽量用 rem

颜色之外,容易被忽略的是字号和间距 token 的单位。如果字号 token 全写成 px,用户在浏览器里放大字体、或把系统默认字号调大,页面会纹丝不动——因为 px 是绝对单位,不跟随根字号。对视力不好的用户,这等于把无障碍的一条退路堵死了。

更稳妥的是让字号 token 走 rem,间距 token 视情况混用:

1:root {
2  --font-size-base: 1rem;      /* 跟随用户设置的根字号 */
3  --font-size-sm: 0.875rem;
4  --space-4: 1rem;             /* 排版相关间距用 rem,随字号缩放 */
5  --border-width: 1px;         /* 描边这类物理细节保持 px */
6}

判断标准是:跟“字”有关、希望随用户缩放的,用 rem;跟“物理像素”有关、不该缩放的(1px 描边、图标对齐),保持 px。token 分层的好处在这里又体现了一次——单位策略只需要在定义 token 的那一层想清楚,业务代码消费 var(--font-size-base) 时根本不用关心底下是 rem 还是 px

规范要靠工具兜底,不能只靠自觉

“禁止业务新增裸色值”这条规矩,光写进文档没用,两个月后一定有人图快写回 color: #333。要让它真正生效,得让工具在提交前就拦下来。

stylelint 有现成的规则可以约束这件事。比如禁止直接写十六进制颜色,强制走变量:

1{
2  "rules": {
3    "declaration-property-value-disallowed-list": {
4      "/^color/": ["/#([0-9a-fA-F]{3,8})/", "/^rgb/"],
5      "background-color": ["/#([0-9a-fA-F]{3,8})/"]
6    }
7  }
8}

再配合 stylelint-declaration-strict-value 这类插件,可以强制颜色、间距、圆角这几类属性必须用 var() 或指定的 token 函数,写死值直接报错。把它挂到 lint-staged 上,提交时自动跑,裸值根本进不了仓库。

不过工具也别一刀切。布局相关的 marginpadding 有时确实需要页面自定义,全禁掉反而逼着大家写各种绕过注释。我的做法是只对“视觉决策”类属性严管——颜色、字号、圆角、阴影、层级色,这些是设计系统的核心资产;布局间距可以宽松些,或者只对一小批离散值(--space-*)做提示而非报错。规则严到什么程度,取决于团队的接受度,太严会被绕过,反而失去意义。

落地顺序

设计系统不适合一口吃成胖子,顺序反了比不做还乱。我会先把颜色、字号、间距、圆角、阴影这些基础 token 整理出来,再给核心组件接入语义 token;等这两层立住了,才轮到禁止业务新增裸色值、建一个页面把 token 和组件状态摆出来给团队对照;主题切换、多端生成、和设计工具打通这些事放在最后——它们锦上添花,但前提是地基已经铺好,不然主题切来切去,切的还是一堆各写各的裸值。

回到开头那个项目:#1677ff#1890ffrgb(22, 119, 255)var(--blue-6) 四种写法各管一摊,根子就是缺了这套顺序里的第一步——没有一个语义 token 把"主色"这件事钉死。补上之后,设计改一次品牌色,改的是 --color-brand-primary 这一行,不用再满仓库搜索替换、改完还担心漏改。