设计系统 Token 落地:别让颜色散落在组件里

0阅读9分钟

从一个颜色翻车现场说起

去年接手一个内部 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-500space-16不含使用意图,纯粹描述值
Alias Token(语义别名层)描述使用场景color-text-primarycolor-surface-elevated绑定用途,可随主题变化
Component Token(组件层)描述特定组件状态button-primary-bginput-border-radius粒度最细,直接映射组件属性

这三层构成了一个从「原始值」到「具体用途」的语义梯度。Google 的 Material Design 3 在 2022 年推出的 Token 体系也采用了类似的三层架构,并将其进一步细分为 system tokens(系统级)和 component tokens(组件级)。

流程图画布 · 115%
Mermaid 流程图加载中...

从这张流程图可以看到,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 命名也是。

我见过的命名翻车主要集中在两种极端:

  1. 纯值命名color-blue-500font-size-14。优点是直白,缺点是一旦值变了名字就误导。当 color-blue-500 的值从蓝色变成绿色时,名字变成了 bug。
  2. 过度语义命名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 名里包含了组件名(比如 buttoncard),它大概率应该属于组件 Token 层;如果包含了颜色名(比如 bluered),它应该属于基础层。语义层的 Token 名应该只包含用途关键词:primarymutedsubtleelevateddanger

分层管理:什么时候该加 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 DictionaryJSONStyle DictionaryCSS/JS/SCSS/Android/iOS多端、多品牌、大型团队
Tailwind CSS ConfigJS/TSTailwind JITCSS utility classes单端 Web、工具类优先
CSS Custom Properties 手写CSSCSS小型项目、无多端需求
Figma Tokens Plugin + Style DictionaryFigma → JSONStyle DictionaryCSS/JS/SCSS设计工具驱动

检查清单

规划阶段

  • 确认 Token 需要覆盖的属性类型(颜色、间距、字号、圆角、阴影、动画时长)
  • 确定分层策略:基础层 → 语义层 → 组件层,明确每层的命名规则
  • 确认需要支持的主题数量(亮/暗/多品牌),提前设计主题切换机制
  • 确认 Token 的跨端需求(Web + iOS + Android?还是仅 Web?)

命名与定义

  • 基础层 Token 使用「属性-值」命名:color-blue-500space-16
  • 语义层 Token 使用「属性-用途」命名:color-text-primarycolor-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 体系就能稳定运转。不需要花哨的工具,不需要一步到位的完美架构——从统一命名和分层引用开始,逐步迭代就好。

参考资料

评论 0

0 / 1000