--- title: "设计令牌:把设计规范变成可执行的代码" date: "2025-02-20" summary: "设计系统最容易失效的地方不是设计稿,而是设计与代码之间的翻译过程。用 Tailwind v4 的 @theme 让令牌成为唯一事实来源。" tags: ["设计系统", "Tailwind", "CSS"] draft: false --- 每个团队都遇到过这种对话: > 「这个按钮的圆角怎么是 8px?设计稿是 12px。」 > 「我随手写的,没注意。」 问题不在于谁写错了,而在于**规范停留在文档里,没有变成代码**。 ## 令牌是设计与代码之间的契约 一份设计规范通常包含颜色、字体、间距、圆角、阴影。这些值如果只写在 Figma 和 Markdown 里,它们就只是**建议**;只有当它们变成代码里有名字的变量,才成为**约束**。 关键在于给值起名,而不是记住值: | 不要这样写 | 要这样写 | | --- | --- | | `border-radius: 12px` | `rounded-lg` | | `color: #0a0a0a` | `text-ink` | | `padding: 24px` | `p-xl` | 前者的语义是「12 像素」,后者的语义是「标准卡片圆角」。当设计决定把标准圆角改成 14px 时,前者需要全局搜索替换,后者只需要改一行。 ## Tailwind v4 的 @theme Tailwind v4 把配置从 JavaScript 搬回了 CSS。用 `@theme` 声明的变量会同时具备两种身份:**CSS 自定义属性**和**生成工具类的依据**。 ```css @theme { --color-ink: #0a0a0a; --color-brand-green: #00d4a4; --radius-lg: 12px; --spacing-xl: 24px; } ``` 声明之后,`text-ink`、`bg-brand-green`、`rounded-lg`、`p-xl` 这些工具类会自动可用,同时也可以在原生 CSS 里直接引用: ```css .card { border-radius: var(--radius-lg); padding: var(--spacing-xl); border: 1px solid var(--color-hairline); } ``` 这就是单一事实来源的价值——工具类和手写 CSS 读的是**同一份变量**,不可能对不上。 ## 颜色:先定语义,再定色值 新手容易直接把调色板搬进来:`--color-gray-100`、`--color-gray-200`、`--color-blue-500`。这能工作,但它没有表达**用途**。 更好的方式是按角色命名: ```text --color-ink 主文字 --color-steel 三级文字 --color-canvas 页面背景 --color-surface 次级表面 --color-hairline 分割线 --color-brand-green 品牌强调色 ``` 这样命名之后,暗色模式的实现会变得非常自然——同一个语义变量换一组值即可,组件代码一行都不用改。反过来,如果组件里写的是 `bg-gray-100`,切暗色时就只能靠 `dark:` 前缀逐处覆盖。 ## 字号的陷阱:行高和字距 排版令牌如果只存字号,会丢掉一半信息。同一个 16px,正文需要 1.5 倍行高,标题可能只需要 1.2。 Tailwind v4 允许把行高和字距直接绑定到字号上: ```css @theme { --text-heading-2: 36px; --text-heading-2--line-height: 1.2; --text-heading-2--letter-spacing: -0.5px; } ``` 现在 `text-heading-2` 一次带出行高和字距,不需要每次手写 `text-4xl leading-tight tracking-tight`。 还有一个细节值得注意:**负字距应该随字号递增**。大标题需要更紧的字距才显得结实,小字号则应该放松甚至归零。 ```text 72px → letter-spacing: -2px 36px → letter-spacing: -0.5px 18px → letter-spacing: 0 ``` ## 间距:八点网格之外的例外 八点网格(8px 递增)是常见约定,但直接用它做类名会很难读:`p-3` 到底是 12px 还是 16px? 按尺码命名的可读性更好: ```css @theme { --spacing-xxs: 4px; --spacing-xs: 8px; --spacing-sm: 12px; --spacing-md: 16px; --spacing-lg: 20px; --spacing-xl: 24px; --spacing-section: 64px; --spacing-hero: 120px; } ``` 注意最后两个:`section` 和 `hero` 是**语义间距**。区块之间 64px、Hero 区 120px,这类值在页面上反复出现,给它们名字比记数字可靠得多。 ## 用类型约束收口 令牌再多,也需要有人保证组件只从令牌里取值,而不是随手写魔法数字。`class-variance-authority` 能把变体定义收敛到一处: ```ts const buttonVariants = cva("inline-flex items-center rounded-full", { variants: { variant: { primary: "bg-primary text-on-primary", secondary: "border border-hairline text-ink", accent: "bg-brand-green text-primary", }, size: { md: "px-lg py-[10px] text-button-md", sm: "px-md py-xs text-body-sm", }, }, defaultVariants: { variant: "primary", size: "md" }, }); ``` 组件使用者只能在 `primary | secondary | accent` 里选,写不出 `bg-[#0a0a0a]` 这种绕过令牌的代码。**把约束交给类型系统,比交给代码评审更可靠。** ## 一条经验 设计令牌的价值不在于「变量很多」,而在于**边界清晰**。真正需要严格约束的是颜色和间距——它们决定了视觉的一致性;而字号阶梯可以适度宽松,因为排版本来就需要灵活性。 最后一点:令牌的命名应该描述**意图**,而不是外观。`brand-green` 比 `mint` 好,`danger` 比 `red` 好。外观会变,意图通常不会。