Chakra UI Design Token 从 0 到 1:语义 Token、主题扩展与组件一致性治理
Chakra UI Design Token 从 0 到 1:语义 Token、主题扩展与组件一致性治理
这篇文章是一个 Chakra UI 的落地案例:如果你的 Web UI 技术栈是 Chakra UI, 怎么从“散装样式”起步,最后得到一套可复用、可演进、可验证的主题与组件体系。
这类工程的难点通常不在“能不能做出一套主题”,而在于:如何把主题变成可执行的协作契约。 让后续新增页面、改品牌、做暗黑、做 A/B 主题时,团队不会重新回到“每个页面自己写一份样式”的混乱状态。
TL;DR(30 秒讲清楚)
- 先分层:global scales(色板/间距/圆角/字号)→ semantic tokens(bg/text/border/primary…)→ component variants(按钮/卡片/输入框)。
- 再收敛:业务组件尽量只用 semantic/component token,不直接写 hex/px/rgba。
- 最后加护栏:类型提示(token 名可补全)+ lint(阻断新散装值)+ 视觉回归(抽样页面截图对比)。
问题定义(Problem Statement)
在 Chakra UI 项目中建设一套可持续演进的主题系统:让颜色/间距/圆角/排版统一由 Token 驱动; 让暗黑模式与多主题演进只需要改语义映射而不是改业务页面; 让 Button/Card/Input 等核心组件通过 variants 收敛样式自由度; 并通过类型提示、lint 与回归验证把“一致性”固化成工程能力。
关键伪代码(读者可复现)
1) Theme 结构:foundations + semanticTokens + components overrides
Chakra 的落地形态非常清晰:你最终会得到一个 extendTheme 的入口。 关键不是“把值塞进去”,而是把你的体系按职责拆开:foundations 放 global scales,semanticTokens 放语义映射(含暗黑),components 放组件契约(variants)。
伪代码:theme 入口(结构示意)
import { extendTheme } from '@chakra-ui/react';
import colors from './foundations/colors';
import space from './foundations/space';
import radii, { semanticRadii } from './foundations/radii';
import { semanticShadows } from './foundations/shadows';
import textStyles from './foundations/textStyles';
import Button from './components/button';
import BaseCard from './components/baseCard';
import InputContent from './components/inputContent';
const semanticTokens = {
colors: {
// 语义化(示例):bg/text/border/primary...
},
shadows: semanticShadows,
radii: semanticRadii,
};
export const theme = extendTheme({
colors,
space,
radii,
textStyles,
semanticTokens,
components: { Button, BaseCard, InputContent },
});
2) semantic token 的关键设计:把“交互色板”做成可参数化的 palette
UI 一致性最容易失控的点,是“交互状态”:hover/active/disabled/focus。 如果业务页面自己拼状态色,你一定会得到 N 套行为。
一个非常实用的做法是把“交互色板”抽象成语义 palette(例如 primary / secondary / gray), 每个 palette 固定提供一组槽位:fg、contrast、subtle、muted、emphasized、solid、focusRing、hover。
伪代码:semantic palette(示例)
export const semanticColors = {
primary: {
contrast: { default: 'blackAlpha.0', _dark: 'blackAlpha.0' },
fg: { default: 'yellow.800', _dark: 'yellow.300' },
subtle: { default: 'yellow.150', _dark: 'yellow.900' },
muted: { default: 'yellow.200', _dark: 'yellow.800' },
emphasized: { default: 'yellow.300', _dark: 'yellow.700' },
solid: { default: 'yellow.500', _dark: 'yellow.500' },
focusRing: { default: 'yellow.400', _dark: 'yellow.400' }
},
bg: {
panel: { default: 'whiteAlpha.0', _dark: 'gray.950' },
subtle: { default: 'gray.100', _dark: 'gray.880' }
},
text: {
fg: { default: 'gray.960', _dark: 'gray.960' },
fgMuted: { default: 'gray.900', _dark: 'gray.350' }
}
};
有了这个 palette,你的按钮/输入框/卡片就可以“参数化”:同一套组件逻辑,通过传入 colorPalette 切换不同的色彩风格, 而不是复制粘贴一份样式。
伪代码:通用按钮(基于 colorPalette)
export const ShareButtonStyle = {
variants: {
solid: (props) => ({
bg: `${props.colorPalette}.solid`,
color: `${props.colorPalette}.contrast`,
_hover: { bg: `${props.colorPalette}.focusRing` }
}),
subtle: (props) => ({
bg: `${props.colorPalette}.subtle`,
color: `${props.colorPalette}.fg`,
_hover: { bg: `${props.colorPalette}.muted` }
})
}
};
3) textStyles:用“可读的组合键”统一排版,并让业务只选 token
排版(字号/行高/字重/字体)是另一个容易失控的点。Chakra 的 textStyle 非常适合做排版 Token:业务侧只需写一个键名即可复用整套排版配置。
伪代码:textStyles(示例)
export const textStyles = {
'title-xl-bold': { fontSize: 'xl', lineHeight: 'xl', fontWeight: 'bold', fontFamily: 'heading' },
'body-md-medium': { fontSize: 'md', lineHeight: 'md', fontWeight: 'medium', fontFamily: 'body' },
'body-sm-normal': { fontSize: 'sm', lineHeight: 'sm', fontWeight: 'normal', fontFamily: 'body' }
};
业务消费:不要再手写 fontSize="14px" 这类散装值
<Text textStyle="body-sm-normal" color="text.fgMuted">
Subtext
</Text>
4) cssVarsRoot:别忘了 portals 与预览容器,否则“弹层样式会漂移”
Token 工程经常遇到一个非常隐蔽的坑:页面主体用 token 没问题,但 Menu/Popover/Modal 这类组件是 portal 渲染的, 如果你的 CSS variables 只挂在某个容器下,它们就拿不到正确的 token。
Chakra 提供了一个非常工程化的能力:cssVarsRoot。 你可以把变量根同时挂到主应用根节点、预览容器(例如主题编辑器的 .custom-theme)、以及 portal 容器上。
伪代码:ChakraProvider 注入(示例)
<ChakraProvider
theme={theme}
cssVarsRoot={['#__next', '.custom-theme', '.chakra-portal']}
>
{children}
</ChakraProvider>
5) 类型提示:让 token 名“写错就报错”,而不是上线才发现
只要你的 token 体系足够大,“拼写错误”就会成为真实成本。尤其是 textStyles 这种长键名:写错一个字符,UI 就会悄悄回退到默认样式。
一个可落地做法是:在 CI 或本地命令里生成 Chakra 主题的类型定义,让 IDE 对 token 名有提示(并且让错误更早暴露)。
伪代码:生成主题类型(示例)
# 从 theme 生成 token types(示意)
npx @chakra-ui/cli tokens your-project/src/styles/theme/index.ts \
--out your-project/node_modules/@chakra-ui/.../theming.types.d.ts
真实链路:一次“从散装到 token”的迁移怎么推进?
- 盘点:先用搜索把散装值找出来(hex/rgba/px),列出 Top N 高频值与使用场景。
- 定标:把颜色/间距/圆角/字号整理成 scales(global tokens),先保证“值统一”。
- 语义化:用 semantic tokens 把“页面语义”抽出来(bg/text/border + 交互 palette)。
- 组件收敛:先收敛 Button/Input/Card 这类高复用组件(variants),让业务不再拼交互状态。
- 护栏上线:加类型提示生成与 lint 门禁,阻断新的散装值进入。
- 回归验证:抽样 Light/Dark、移动/桌面、关键页面截图对比;灰度上线后观察一致性缺陷与反馈。
指标与验证(最小闭环)
- 散装值数量:代码库 raw hex/rgba/px 的出现次数(目标是持续下降)。
- Token 覆盖率:核心组件是否主要使用
bg.*/text.*/border.*/primary.*等语义 token。 - 视觉回归结果:抽样页面在迁移前后差异是否符合预期(最好能自动化像素 diff)。
- 一致性缺陷数:UI 不一致类 bug/反馈的数量是否下降。
通过标准(建议):
- 可发现:token/types 生成成功,IDE 可补全,减少拼写错误与散装值。
- 可约束:新增散装值(hex/rgba/px)能被检索或 lint 发现,且数量趋势下降。
- 可回归:抽样关键页面(Light/Dark、Mobile/Desktop、Marketing/App)无明显视觉回退。
最小可复现检查清单(示例)
# 1) 搜索散装值(示例:hex + rgba + px)
rg -n \"#[0-9a-fA-F]{3,8}\\b|rgba\\(|\\b\\d+px\\b\" your-project/src
# 2) 生成 token 类型提示(示例)
npm run generate-theme-types
# 3) 抽样验证:至少覆盖一组 portal 组件(Menu/Popover/Modal)
npm run dev
预期与判定(建议):
- 散装值:能稳定找出新增散装值位置;推荐门禁口径是“新增散装值 = 0(或白名单)”,并让总量趋势持续下降。
- 类型提示:
generate-theme-types成功;常用 token/textStyles 在 IDE 可补全,重命名能被类型报错兜住。 - portal 抽样:Menu/Popover/Modal 的背景/边框/阴影与页面主体同口径,不出现“弹层像另一个产品”的主题漂移。
不通过先查:portal 容器是否被主题变量根覆盖(例如 cssVarsRoot/portal root);散装值 lint 是否真的在 CI 阻断; types 生成脚本是否在构建/开发链路里跑过、产物是否被正确引入。
FAQ
Q1:为什么 semantic token 里要分 bg/text/border,还要做 primary 这种 palette?
bg/text/border 解决的是“页面语义一致”,palette 解决的是“交互状态一致”。如果只做其中一个,UI 一致性仍会在 hover/disabled/focus 上崩掉。
Q2:业务代码完全禁止写 hex/px 会不会太极端?
建议“默认禁止、允许白名单”。真正必要的散装值(例如第三方组件临时兼容)可以允许,但要能被审计:在哪些地方、为什么存在、什么时候迁移掉。
Q3:dark mode 一定要一开始就做吗?
不一定。但 semantic token 的设计要为它留出空间:至少用 default/_dark(或等价机制)表达未来映射, 避免后面要做暗黑时把业务页面全部重写。
Q4:为什么要关心 portal?
因为 portal 组件(Menu/Popover/Modal)往往承载最“显眼”的交互与视觉层级。一旦 token 在 portal 里拿不到,你会看到最尴尬的现象: 页面主体样式统一,但弹层像另一个产品。
Q5:textStyles 的键名很长,怎么避免难用?
- 键名可读:用
body-sm-normal这种“语义-尺寸-字重”的组合键。 - 类型提示:让 IDE 能补全,减少记忆负担。
- 减少变体:不要为每个页面创建新 textStyle;优先复用,新增必须有理由。
Q6:组件 variants 会越积越多怎么办?
把 variants 当成“公共 API”:新增要评审、要文档示例、要说明使用场景。能用 token 组合解决的,不要新增 variant;能复用的,不要新增语义上重复的 variant。
Q7:怎么分阶段迁移,避免一次性“大爆炸”?
建议按影响面从小到大推进:先统一 foundations(global scales),再补 semantic tokens(先覆盖 bg/text/border 等高频语义), 然后优先收敛 Button/Input/Card 这类核心组件(variants),最后才是业务页面扫尾。迁移期允许 alias/兼容层存在,但要配合 lint 把“新增散装值”挡住。
Q8:lint/门禁怎么落,才能不把仓库一夜打红?
经验做法是“先基线、再阻断新增”:先统计现有散装值基线(存档数字与 Top 文件),然后在 CI 里只阻断“新增散装值”进入; 对确实需要临时散装的地方走白名单/注释豁免,并要求写清原因与清理期限。