设计系统 Token 落地:别让颜色散落在组件里
从一个颜色翻车现场说起
去年接手一个内部 SaaS 项目的设计重构,设计师发来一份新品牌色板:主色从 #1677FF 换成 #2563EB,辅助色从 #52C41A 换成 #16A34A。我打开项目全局搜索 #1677FF,结果跳出来 47 处匹配——分布在 12 个组件文件、3 个全局样式文件、2 个主题配置文件里,还有 5 处写的是 color: blue。
这不是个案。大多数前端项目在没有 Token 体系之前,颜色值的存储方式本质上和随机变量差不多:有的写在 CSS 文件的自定义属性里,有的硬编码在 styled-components 里,有的藏在 Tailwind 的 class 里,有的直接写在行内 style 中。设计师说「改个颜色」,工程师需要理解的不只是「改哪里」,而是「哪些地方算同一个颜色」。
Design Tokens 要解决的正是这个问题。它不是又一个 CSS 框架,而是一套把视觉决策从实现代码里抽出来的中间层。当品牌色、间距、字号、圆角、阴影这些视觉原子被命名、分类、分层之后,修改一个 Token 就能驱动一组视觉变更,而不是在几十个文件里逐个替换。
Token 的本质:设计决策的具名变量
W3C Design Tokens 社区组(Design Tokens Community Group,简称 DTCG)在 2023 年发布了 Design Tokens Format 的社区组草案,对 Token 的定义是:「A design token is a named entity that stores a visual design attribute.」翻译过来就是:设计 Token 是一个存储了视觉设计属性的具名实体。
这个定义听起来平淡无奇,但它点出了一个关键转变——Token 不是值本身,而是值的「命名」。#1677FF 只是一个十六进制颜色值,color-brand-primary 才是一个 Token。前者只告诉你「这是什么颜色」,后者告诉你「这个颜色用来干什么」。
Nathan Curtis 在 2017 年发表的「Tokens in Design Systems」里把 Token 分成三类:
| 层级 | 定位 | 命名示例 | 特征 |
|---|---|---|---|
| Global Token(全局基础层) | 描述原始视觉值 | color-blue-500、space-16 | 不含使用意图,纯粹描述值 |
| Alias Token(语义别名层) | 描述使用场景 | color-text-primary、color-surface-elevated | 绑定用途,可随主题变化 |
| Component Token(组件层) | 描述特定组件状态 | button-primary-bg、input-border-radius | 粒度最细,直接映射组件属性 |
这三层构成了一个从「原始值」到「具体用途」的语义梯度。Google 的 Material Design 3 在 2022 年推出的 Token 体系也采用了类似的三层架构,并将其进一步细分为 system tokens(系统级)和 component tokens(组件级)。
从这张流程图可以看到,Token 不是单一产物,而是一条从设计到多端产物的管道。Style Dictionary(Amazon 开源,目前由 Joren Broekema 维护)是这条管道中最广泛使用的构建工具——它读取 JSON 格式的 Token 源文件,通过 transform 和 format 管线输出 CSS、JS、SCSS、Android XML、iOS Swift 等多端格式。
Supernova.io 发布的《State of Design Tokens 2024》报告调研了全球使用 Token 的设计团队,结果显示 85% 的受访者认为「Token 别名化(aliasing)」是提升可维护性、一致性和可扩展性的最佳实践。换句话说,直接用基础色名的团队,大多数后来都走上了语义化的路。
案例一:品牌色散落导致换肤翻车
场景
项目初期为了快速上线,组件里直接写死了 HEX 值。主按钮用了 #1677FF,链接用了 #1890FF,图标高亮用了 #4096FF——三个「蓝色」各自独立。
翻车
品牌升级时设计师要求把所有蓝色统一为新的品牌蓝 #2563EB。全局替换 #1677FF 只解决了一部分,另外两个蓝色没有出现在品牌色板里,需要手动逐个排查。更麻烦的是,有些组件用了 rgba(22, 119, 255, 0.1) 做背景,全局搜索根本找不到。
修复
引入语义 Token 层,组件不再直接引用 HEX,而是引用 color-brand-primary。所有蓝色变体(实色、悬浮、禁用、背景透明度)都从同一个基础 Token 派生。
/* ❌ 坏做法:颜色值散落在组件里 */
.primary-button {
background-color: #1677FF; /* 主色硬编码 */
}
.primary-button:hover {
background-color: #4096FF; /* 悬浮色独立写死 */
}
.link {
color: #1890FF; /* 另一个「蓝色」,和主色没有关联 */
}
/* ✅ 好做法:通过语义 Token 引用 */
.primary-button {
background-color: var(--color-brand-primary);
}
.primary-button:hover {
background-color: var(--color-brand-primary-hover);
}
.link {
color: var(--color-brand-primary); /* 与按钮共享同一 Token */
}差异的核心在于:坏做法里三个蓝色是三个孤立的值,好做法里它们是同一个 Token 的不同状态。品牌色变更时,只需要修改 Token 定义,不需要搜索组件代码。
// tokens/color/brand.json
{
"color": {
"brand": {
"primary": { "value": "#2563EB", "type": "color" },
"primary-hover": { "value": "#3B82F6", "type": "color" },
"primary-active": { "value": "#1D4ED8", "type": "color" },
"primary-bg": { "value": "rgba(37, 99, 235, 0.08)", "type": "color" }
}
}
}案例二:暗色模式适配时基础色直接引用的连锁问题
场景
项目需要支持暗色模式。大部分组件用了 CSS 变量 --color-bg 和 --color-text,切换主题时修改这两个变量的值就行。但有几个组件直接用了 var(--color-gray-50) 做背景——这是一个基础色 Token,在亮色和暗色主题里都是同一个值(浅灰)。
翻车
暗色模式下,--color-gray-50 仍然是浅灰 #FAFAFA,导致几个卡片的背景和白色文字几乎融为一体。排查发现,开发者当时认为「灰色就是灰色,不需要区分主题」,直接用基础色 Token 跳过了语义层的映射。
修复
禁止组件直接引用基础色 Token。所有颜色引用必须经过语义层:--color-surface-card 在亮色主题映射到 gray-50,在暗色主题映射到 gray-800。
/* ❌ 坏做法:组件直接引用基础色 */
.card {
background-color: var(--color-gray-50); /* 暗色模式下仍然浅灰 */
border: 1px solid var(--color-gray-200); /* 暗色模式下边框几乎不可见 */
}
/* ✅ 好做法:组件引用语义 Token */
.card {
background-color: var(--color-surface-card); /* 跟随主题变化 */
border: 1px solid var(--color-border-subtle); /* 跟随主题变化 */
}语义 Token 在主题配置文件中分别定义:
// tokens/theme/light.json
{
"color": {
"surface": { "card": { "value": "{color.gray.50}", "type": "color" } },
"border": { "subtle": { "value": "{color.gray.200}", "type": "color" } }
}
}
// tokens/theme/dark.json
{
"color": {
"surface": { "card": { "value": "{color.gray.800}", "type": "color" } },
"border": { "subtle": { "value": "{color.gray.600}", "type": "color" } }
}
}这里的关键是 Token 的「引用语法」:{color.gray.50} 是 Style Dictionary 的引用写法,表示这个语义 Token 的值来自基础层的 color.gray.50。主题切换时,语义 Token 的引用目标变了,但 Token 名不变,组件代码也不需要改。
案例三:多品牌支持的 Token 架构
场景
产品需要支持三个品牌(A、B、C),三个品牌的主色、辅助色、字体都不相同,但组件库是同一套。
翻车
最初的做法是给每个品牌建一套独立的 CSS 变量文件,在运行时通过切换 <link> 标签加载不同文件。问题在于:三个品牌的 Token 命名不一致(品牌 A 叫 --main-color,品牌 B 叫 --brand-primary),导致组件里不得不写条件判断,或者维护三套组件样式。
修复
统一 Token 命名规范。所有品牌共享同一套语义 Token 名,区别只在于值不同。运行时通过给 <html> 添加 data-theme="brand-a" 属性来切换变量组。
/* ❌ 坏做法:每个品牌各自命名 */
[data-brand="a"] { --main-color: #1677FF; }
[data-brand="b"] { --brand-primary: #DC2626; }
[data-brand="c"] { --primary: #7C3AED; }
/* 组件里需要判断品牌,或者维护三套样式 */
/* ✅ 好做法:统一语义 Token 名,只改值 */
[data-theme="brand-a"] {
--color-brand-primary: #1677FF;
--color-brand-primary-hover: #4096FF;
--font-family-heading: "Inter", sans-serif;
}
[data-theme="brand-b"] {
--color-brand-primary: #DC2626;
--color-brand-primary-hover: #EF4444;
--font-family-heading: "Merriweather", serif;
}
[data-theme="brand-c"] {
--color-brand-primary: #7C3AED;
--color-brand-primary-hover: #8B5CF6;
--font-family-heading: "Space Grotesk", sans-serif;
}
/* 组件代码完全不需要知道当前是哪个品牌 */
.heading {
color: var(--color-brand-primary);
font-family: var(--font-family-heading);
}| 对比维度 | 各自命名 | 统一语义 Token |
|---|---|---|
| 组件代码复杂度 | 需要条件分支或重复样式 | 零品牌感知 |
| 新增品牌成本 | 新建组件样式或复制逻辑 | 新增一组变量定义 |
| Token 命名一致性 | 品牌间无法对齐 | 完全对齐 |
| 设计师沟通成本 | 需要翻译不同品牌的命名 | 使用统一的语义名 |
Token 命名:从值描述到意图描述
Naming is one of the two hard problems in computer science. Token 命名也是。
我见过的命名翻车主要集中在两种极端:
- 纯值命名:
color-blue-500、font-size-14。优点是直白,缺点是一旦值变了名字就误导。当color-blue-500的值从蓝色变成绿色时,名字变成了 bug。 - 过度语义命名:
color-for-save-button-hover-in-checkout-page。优点是精确,缺点是 Token 失去了复用价值,每个场景一个 Token,本质上和硬编码没有区别。
平衡点在「使用意图」层面:Token 名应该描述这个值「在哪里用、做什么」,而不是「值是什么」或「属于哪个页面」。
// ❌ 坏做法:值描述命名
const tokenA = 'color-blue-500'; // 值变了名字就错
const tokenB = 'checkout-save-hover'; // 太具体,无法复用
// ✅ 好做法:意图描述命名
const tokenA = 'color-brand-primary'; // 品牌主色,不限定具体组件
const tokenB = 'color-interactive-hover'; // 交互悬浮色,可复用到任何交互元素Clearleft 在多品牌 Token 实践中提出了一条有用的判断标准:如果一个 Token 名里包含了组件名(比如 button、card),它大概率应该属于组件 Token 层;如果包含了颜色名(比如 blue、red),它应该属于基础层。语义层的 Token 名应该只包含用途关键词:primary、muted、subtle、elevated、danger。
分层管理:什么时候该加 Token
Token 不是越多越好。一个常见的误区是:每当某个页面需要一个特殊样式,就创建一个全局 Token。这会导致 Token 数量膨胀,语义变得模糊。
我遵循的判断框架是:
| 问题 | 答案 | 结论 |
|---|---|---|
| 这个值在多个组件/页面中重复出现吗? | 是 | 值得成为 Token |
| 这个值会随主题(暗色/品牌)变化吗? | 是 | 值得成为语义 Token |
| 这个值只在一个地方使用吗? | 是 | 不需要 Token,直接写值 |
| 这个值是设计系统的「原子」吗? | 是 | 值得成为基础 Token |
| 这个值是从其他 Token 派生的吗? | 是 | 应该是别名 Token,不是新基础 Token |
组件 Token 和语义 Token 的边界也容易混淆。经验法则是:如果一个值只属于某一个组件(比如按钮的圆角),它就是组件 Token;如果多个组件共享同一个值(比如所有容器的圆角),它应该是语义 Token。
/* ❌ 坏做法:把页面级样式提升为全局 Token */
:root {
--color-special-promo-banner-bg: #FF6B6B; /* 只用在一次营销活动 */
}
/* ✅ 好做法:页面级特殊样式直接写在组件或页面文件里 */
.promo-banner {
background-color: #FF6B6B; /* 一次性样式,不需要 Token 化 */
/* 如果后续复用,再提升为 Token */
}Style Dictionary 构建流程
Style Dictionary 的工作方式可以概括为:读取 JSON → 执行 transform → 输出 format。transform 是值的转换(比如把 rgba 转成 HEX,把 px 转成 rem),format 是输出的格式(CSS、JS、SCSS 等)。
// sd.config.js
import StyleDictionary from 'style-dictionary';
const sd = new StyleDictionary({
source: ['tokens/**/*.json'], // Token 源文件
platforms: {
css: {
transformGroup: 'css', // 使用内置 CSS transform 组
buildPath: 'dist/css/',
files: [{
destination: 'variables.css',
format: 'css/variables', // 输出 CSS 自定义属性
}],
},
js: {
transformGroup: 'js',
buildPath: 'dist/js/',
files: [{
destination: 'tokens.js',
format: 'javascript/es6', // 输出 ES6 模块
}],
},
},
});
await sd.build();构建产物会被纳入版本控制或 CI 产物,前端代码直接 import 使用:
// 在组件中使用构建产物
import tokens from '@/design-tokens/tokens';
const Button = styled.button`
background-color: ${tokens.color.brand.primary};
border-radius: ${tokens.border.radius.md};
padding: ${tokens.space.button.default};
`;| 方案 | Token 源格式 | 构建工具 | 输出格式 | 适用场景 |
|---|---|---|---|---|
| Style Dictionary | JSON | Style Dictionary | CSS/JS/SCSS/Android/iOS | 多端、多品牌、大型团队 |
| Tailwind CSS Config | JS/TS | Tailwind JIT | CSS utility classes | 单端 Web、工具类优先 |
| CSS Custom Properties 手写 | CSS | 无 | CSS | 小型项目、无多端需求 |
| Figma Tokens Plugin + Style Dictionary | Figma → JSON | Style Dictionary | CSS/JS/SCSS | 设计工具驱动 |
检查清单
规划阶段
- 确认 Token 需要覆盖的属性类型(颜色、间距、字号、圆角、阴影、动画时长)
- 确定分层策略:基础层 → 语义层 → 组件层,明确每层的命名规则
- 确认需要支持的主题数量(亮/暗/多品牌),提前设计主题切换机制
- 确认 Token 的跨端需求(Web + iOS + Android?还是仅 Web?)
命名与定义
- 基础层 Token 使用「属性-值」命名:
color-blue-500、space-16 - 语义层 Token 使用「属性-用途」命名:
color-text-primary、color-surface-elevated - 组件层 Token 使用「组件-属性-状态」命名:
button-primary-bg-hover - 所有语义层 Token 必须引用基础层,不能直接写死值
- 避免在 Token 名中包含具体页面名或业务功能名
构建与集成
- 选择构建工具(Style Dictionary / Tailwind Config / 自定义脚本)
- Token 源文件纳入版本控制,构建产物通过 CI 发布
- 主题切换通过 CSS 变量 +
data-theme属性实现,不依赖 JS 运行时替换样式 - 添加 Token 变更的 lint 规则(比如禁止新增与现有 Token 值重复的新 Token)
治理与维护
- 新增 Token 需要说明使用场景和复用预期,不允许「一次性全局 Token」
- 定期审计:删除 90 天内未被引用的 Token
- 设计师和工程师使用同一份 Token 文档,避免命名歧义
- Token 变更遵循语义化版本:新增 Token 是 minor,删除或改名是 major
总结
Token 不是一个技术概念,而是一个工程概念。它的价值不在于「把 #1677FF 换成 var(--primary)」这种表面替换,而在于让视觉变更有一个可控的、可追踪的、可自动化的入口。
落地 Token 体系时,三个最容易犯的错误是:命名过早语义化(给只用一次的样式创建语义 Token)、命名过晚语义化(组件里直接写 color-blue-500 而不经过语义层)、以及 Token 膨胀(每个页面需求都变成全局 Token)。
避免这三个错误,Token 体系就能稳定运转。不需要花哨的工具,不需要一步到位的完美架构——从统一命名和分层引用开始,逐步迭代就好。
参考资料
- W3C Design Tokens Community Group - Design Tokens Format — Design Token 格式的社区组草案,定义了 Token 的 JSON Schema 和互操作标准
- Nathan Curtis - Tokens in Design Systems — Token 分类体系的奠基文章,提出 Global/Alias/Component 三层模型
- Material Design 3 - Design Tokens — Google 的 Material Design 3 Token 体系文档,包含 system tokens 和 component tokens 的完整规范
- Amazon Style Dictionary — Style Dictionary 官方文档,Token 构建工具的核心用法和 API 参考
- Clearleft - Designing with Tokens for a Flexible Multi-Brand Design System — 多品牌 Token 架构的实践案例
- Supernova.io - The State of Design Tokens 2024 — 行业调研报告,包含 Token 采用率、工具趋势和架构模式的量化数据
- zeroheight - Opinionated vs. Global Design Tokens: Simplifying Multi-Brand Design Systems — 多品牌 Token 策略中的 Opinionated 与 Global 两种模式对比