Files
app-261004/content/posts/design-tokens-with-tailwind-v4.mdx
2026-10-08 11:09:37 +08:00

145 lines
5.1 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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` 好。外观会变,意图通常不会。