设计系统和 Design Token:别让颜色和间距散落在业务代码里
设计系统听起来很大,但很多团队真正需要解决的第一件事很朴素:颜色、字号、间距不要散落在业务代码里。
我见过一个项目里同时存在 #1677ff、#1890ff、rgb(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 上,提交时自动跑,裸值根本进不了仓库。
不过工具也别一刀切。布局相关的 margin、padding 有时确实需要页面自定义,全禁掉反而逼着大家写各种绕过注释。我的做法是只对“视觉决策”类属性严管——颜色、字号、圆角、阴影、层级色,这些是设计系统的核心资产;布局间距可以宽松些,或者只对一小批离散值(--space-*)做提示而非报错。规则严到什么程度,取决于团队的接受度,太严会被绕过,反而失去意义。
落地顺序
设计系统不适合一口吃成胖子,顺序反了比不做还乱。我会先把颜色、字号、间距、圆角、阴影这些基础 token 整理出来,再给核心组件接入语义 token;等这两层立住了,才轮到禁止业务新增裸色值、建一个页面把 token 和组件状态摆出来给团队对照;主题切换、多端生成、和设计工具打通这些事放在最后——它们锦上添花,但前提是地基已经铺好,不然主题切来切去,切的还是一堆各写各的裸值。
回到开头那个项目:#1677ff、#1890ff、rgb(22, 119, 255)、var(--blue-6) 四种写法各管一摊,根子就是缺了这套顺序里的第一步——没有一个语义 token 把"主色"这件事钉死。补上之后,设计改一次品牌色,改的是 --color-brand-primary 这一行,不用再满仓库搜索替换、改完还担心漏改。