设计Token与主题切换实现
原创
灵阙教研团队
B 基础 进阶 |
约 9 分钟阅读
更新于 2026-02-28 AI 导读
设计Token与主题切换实现 CSS 自定义属性、暗色主题与动态主题的工程架构 1. Design Token 的本质 Design Token 是将设计决策编码为数据的方法。它不是变量,不是常量,而是"设计决策的最小可传递单元"。一个 Token 携带了名称、值、类型和作用域四个信息维度。 传统做法: button { background: #3B82F6; } <-...
设计Token与主题切换实现
CSS 自定义属性、暗色主题与动态主题的工程架构
1. Design Token 的本质
Design Token 是将设计决策编码为数据的方法。它不是变量,不是常量,而是"设计决策的最小可传递单元"。一个 Token 携带了名称、值、类型和作用域四个信息维度。
传统做法:
button { background: #3B82F6; } <- 硬编码,无法追溯设计意图
Token 做法:
button { background: var(--color-primary); }
|
v
--color-primary: var(--blue-500) <- 语义层
|
v
--blue-500: #3B82F6 <- 全局层
1.1 Token 分类
| 类别 | 说明 | 示例 |
|---|---|---|
| 颜色 | 背景/前景/边框/强调色 | --color-primary: #3B82F6 |
| 字体 | 字族/字号/字重/行高 | --font-size-lg: 18px |
| 间距 | 内边距/外边距/间隙 | --spacing-4: 16px |
| 圆角 | 元素圆角半径 | --radius-md: 8px |
| 阴影 | 投影效果 | --shadow-md: 0 4px 6px ... |
| 动画 | 时长/缓动函数 | --duration-fast: 150ms |
| 边框 | 宽度/样式 | --border-width: 1px |
| 层级 | z-index 管理 | --z-modal: 1000 |
| 不透明度 | 透明度级别 | --opacity-disabled: 0.5 |
2. 三层 Token 架构
2.1 全局层(Global Tokens)
全局 Token 是原始值,不携带语义信息:
:root {
/* 颜色原始值 */
--blue-50: #EFF6FF;
--blue-100: #DBEAFE;
--blue-200: #BFDBFE;
--blue-300: #93C5FD;
--blue-400: #60A5FA;
--blue-500: #3B82F6;
--blue-600: #2563EB;
--blue-700: #1D4ED8;
--blue-800: #1E40AF;
--blue-900: #1E3A8A;
--gray-50: #F9FAFB;
--gray-100: #F3F4F6;
--gray-200: #E5E7EB;
--gray-300: #D1D5DB;
--gray-400: #9CA3AF;
--gray-500: #6B7280;
--gray-600: #4B5563;
--gray-700: #374151;
--gray-800: #1F2937;
--gray-900: #111827;
/* 字号阶梯 */
--font-size-xs: 12px;
--font-size-sm: 14px;
--font-size-base: 16px;
--font-size-lg: 18px;
--font-size-xl: 20px;
--font-size-2xl: 24px;
--font-size-3xl: 30px;
--font-size-4xl: 36px;
/* 间距阶梯 (4px base) */
--spacing-0: 0;
--spacing-1: 4px;
--spacing-2: 8px;
--spacing-3: 12px;
--spacing-4: 16px;
--spacing-5: 20px;
--spacing-6: 24px;
--spacing-8: 32px;
--spacing-10: 40px;
--spacing-12: 48px;
--spacing-16: 64px;
/* 圆角 */
--radius-none: 0;
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-full: 9999px;
}
2.2 语义层(Semantic Tokens)
语义 Token 携带用途信息,引用全局 Token:
:root {
/* 前景色 */
--color-text-primary: var(--gray-900);
--color-text-secondary: var(--gray-600);
--color-text-muted: var(--gray-400);
--color-text-inverse: #FFFFFF;
/* 背景色 */
--color-bg-primary: #FFFFFF;
--color-bg-secondary: var(--gray-50);
--color-bg-tertiary: var(--gray-100);
--color-bg-inverse: var(--gray-900);
/* 品牌色 */
--color-primary: var(--blue-600);
--color-primary-hover: var(--blue-700);
--color-primary-active: var(--blue-800);
--color-primary-subtle: var(--blue-50);
/* 状态色 */
--color-success: #22C55E;
--color-warning: #F59E0B;
--color-error: #EF4444;
--color-info: #3B82F6;
/* 边框 */
--color-border: var(--gray-200);
--color-border-hover: var(--gray-300);
--color-border-focus: var(--blue-500);
/* 字体 */
--font-family-sans: "Source Han Sans SC", "PingFang SC", sans-serif;
--font-family-mono: "JetBrains Mono", "Fira Code", monospace;
/* 阴影 */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
/* 动画 */
--duration-fast: 150ms;
--duration-normal: 250ms;
--duration-slow: 400ms;
--easing-default: cubic-bezier(0.4, 0, 0.2, 1);
--easing-in: cubic-bezier(0.4, 0, 1, 1);
--easing-out: cubic-bezier(0, 0, 0.2, 1);
}
2.3 组件层(Component Tokens)
组件 Token 是最具体的层级,引用语义 Token:
:root {
/* Button */
--btn-bg: var(--color-primary);
--btn-bg-hover: var(--color-primary-hover);
--btn-text: var(--color-text-inverse);
--btn-radius: var(--radius-md);
--btn-padding-x: var(--spacing-4);
--btn-padding-y: var(--spacing-2);
--btn-font-size: var(--font-size-sm);
--btn-font-weight: 500;
/* Card */
--card-bg: var(--color-bg-primary);
--card-border: var(--color-border);
--card-radius: var(--radius-lg);
--card-padding: var(--spacing-6);
--card-shadow: var(--shadow-sm);
/* Input */
--input-bg: var(--color-bg-primary);
--input-border: var(--color-border);
--input-border-focus: var(--color-border-focus);
--input-radius: var(--radius-md);
--input-padding: var(--spacing-2) var(--spacing-3);
--input-font-size: var(--font-size-base);
}
3. 暗色主题实现
3.1 核心策略
暗色主题不是简单地"反转颜色"。正确的做法是在语义层切换映射关系:
亮色模式: 暗色模式:
--color-text-primary: gray-900 --color-text-primary: gray-100
--color-bg-primary: white --color-bg-primary: gray-900
--color-border: gray-200 --color-border: gray-700
--color-primary: blue-600 --color-primary: blue-400 (更亮)
3.2 CSS 实现
/* 亮色主题(默认) */
:root,
[data-theme="light"] {
--color-text-primary: var(--gray-900);
--color-text-secondary: var(--gray-600);
--color-text-muted: var(--gray-400);
--color-bg-primary: #FFFFFF;
--color-bg-secondary: var(--gray-50);
--color-bg-tertiary: var(--gray-100);
--color-border: var(--gray-200);
--color-primary: var(--blue-600);
--color-primary-subtle: var(--blue-50);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
/* 暗色主题 */
[data-theme="dark"] {
--color-text-primary: var(--gray-100);
--color-text-secondary: var(--gray-400);
--color-text-muted: var(--gray-500);
--color-bg-primary: var(--gray-900);
--color-bg-secondary: var(--gray-800);
--color-bg-tertiary: var(--gray-700);
--color-border: var(--gray-700);
--color-primary: var(--blue-400); /* 暗色下用更亮的蓝 */
--color-primary-subtle: rgba(59, 130, 246, 0.15);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4);
}
/* 跟随系统偏好 */
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) {
--color-text-primary: var(--gray-100);
--color-bg-primary: var(--gray-900);
/* ... 同暗色主题 */
}
}
3.3 暗色主题设计规则
| 规则 | 说明 |
|---|---|
| 降低饱和度 | 暗色背景上高饱和色刺眼,降低 10-20% |
| 提升亮度 | 文字颜色用 gray-100 而非 white |
| 层级用亮度区分 | 底层最暗,上层略亮(gray-900 > gray-800 > gray-700) |
| 阴影加深 | 暗背景上阴影需要更高不透明度 |
| 避免纯黑 | 纯黑 (#000) 对比过强,用 gray-900 (#111827) |
| 图片降亮 | 暗色模式下图片 brightness(0.85) |
/* 暗色模式下的图片处理 */
[data-theme="dark"] img:not([data-no-dim]) {
filter: brightness(0.85);
}
/* 暗色模式下的阴影 */
[data-theme="dark"] .card {
/* 用 border 代替 shadow,暗色下 shadow 看不见 */
box-shadow: none;
border: 1px solid var(--color-border);
}
4. 主题切换逻辑
4.1 React 实现
// useTheme.ts
type Theme = 'light' | 'dark' | 'system';
function useTheme() {
const [theme, setTheme] = useState<Theme>(() => {
if (typeof window === 'undefined') return 'system';
return (localStorage.getItem('theme') as Theme) || 'system';
});
useEffect(() => {
const root = document.documentElement;
const systemDark = window.matchMedia('(prefers-color-scheme: dark)');
function apply(t: Theme) {
if (t === 'system') {
root.removeAttribute('data-theme');
} else {
root.setAttribute('data-theme', t);
}
}
apply(theme);
localStorage.setItem('theme', theme);
// 监听系统主题变化
function onSystemChange() {
if (theme === 'system') {
apply('system');
}
}
systemDark.addEventListener('change', onSystemChange);
return () => systemDark.removeEventListener('change', onSystemChange);
}, [theme]);
return { theme, setTheme };
}
// ThemeToggle.tsx
function ThemeToggle() {
const { theme, setTheme } = useTheme();
const options: { value: Theme; label: string }[] = [
{ value: 'light', label: '浅色' },
{ value: 'dark', label: '深色' },
{ value: 'system', label: '跟随系统' },
];
return (
<div role="radiogroup" aria-label="主题选择">
{options.map(opt => (
<button
key={opt.value}
role="radio"
aria-checked={theme === opt.value}
onClick={() => setTheme(opt.value)}
className={theme === opt.value ? 'active' : ''}
>
{opt.label}
</button>
))}
</div>
);
}
4.2 避免闪烁(FOUC)
<!-- 在 <head> 中内联执行,避免主题闪烁 -->
<script>
(function() {
var theme = localStorage.getItem('theme');
if (theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.setAttribute('data-theme', 'dark');
} else if (theme === 'light') {
document.documentElement.setAttribute('data-theme', 'light');
}
})();
</script>
4.3 Next.js App Router 中的主题
// app/layout.tsx
import { cookies } from 'next/headers';
export default function RootLayout({ children }) {
const cookieStore = cookies();
const theme = cookieStore.get('theme')?.value || 'system';
return (
<html
lang="zh-CN"
data-theme={theme !== 'system' ? theme : undefined}
suppressHydrationWarning
>
<head>
<script dangerouslySetInnerHTML={{
__html: `/* 主题闪烁防护脚本 */`
}} />
</head>
<body>{children}</body>
</html>
);
}
5. Tailwind CSS 集成
5.1 Token 映射到 Tailwind
// tailwind.config.js
const tokens = require('./dist/tailwind-tokens');
module.exports = {
darkMode: ['class', '[data-theme="dark"]'],
theme: {
colors: {
primary: {
DEFAULT: 'var(--color-primary)',
hover: 'var(--color-primary-hover)',
subtle: 'var(--color-primary-subtle)',
},
text: {
primary: 'var(--color-text-primary)',
secondary: 'var(--color-text-secondary)',
muted: 'var(--color-text-muted)',
},
bg: {
primary: 'var(--color-bg-primary)',
secondary: 'var(--color-bg-secondary)',
tertiary: 'var(--color-bg-tertiary)',
},
border: {
DEFAULT: 'var(--color-border)',
},
success: 'var(--color-success)',
warning: 'var(--color-warning)',
error: 'var(--color-error)',
},
borderRadius: {
none: 'var(--radius-none)',
sm: 'var(--radius-sm)',
md: 'var(--radius-md)',
lg: 'var(--radius-lg)',
xl: 'var(--radius-xl)',
full: 'var(--radius-full)',
},
boxShadow: {
sm: 'var(--shadow-sm)',
md: 'var(--shadow-md)',
lg: 'var(--shadow-lg)',
},
},
};
5.2 使用示例
<!-- 使用 Token 化的 Tailwind 类名 -->
<div class="bg-bg-primary text-text-primary border border-border rounded-lg shadow-md p-6">
<h2 class="text-text-primary text-xl font-semibold">标题</h2>
<p class="text-text-secondary mt-2">描述文字</p>
<button class="bg-primary text-white rounded-md px-4 py-2 hover:bg-primary-hover">
按钮
</button>
</div>
<!-- 暗色模式自动适配,无需添加 dark: 前缀 -->
6. 动态主题(品牌定制)
6.1 运行时主题覆盖
function applyBrandTheme(brandConfig: BrandConfig) {
const root = document.documentElement;
// 覆盖语义 Token
if (brandConfig.primaryColor) {
const hsl = hexToHSL(brandConfig.primaryColor);
root.style.setProperty('--color-primary', brandConfig.primaryColor);
root.style.setProperty('--color-primary-hover', adjustLightness(hsl, -10));
root.style.setProperty('--color-primary-active', adjustLightness(hsl, -20));
root.style.setProperty('--color-primary-subtle', adjustLightness(hsl, 45));
}
if (brandConfig.fontFamily) {
root.style.setProperty('--font-family-sans', brandConfig.fontFamily);
}
if (brandConfig.borderRadius) {
root.style.setProperty('--radius-md', brandConfig.borderRadius);
}
}
6.2 预设品牌主题
{
"themes": {
"corporate-blue": {
"primary": "#1E40AF",
"accent": "#F59E0B",
"fontFamily": "\"Source Han Sans SC\", sans-serif",
"radius": "4px"
},
"startup-purple": {
"primary": "#7C3AED",
"accent": "#06B6D4",
"fontFamily": "\"Inter\", \"Source Han Sans SC\", sans-serif",
"radius": "12px"
},
"fintech-green": {
"primary": "#059669",
"accent": "#3B82F6",
"fontFamily": "\"Source Han Sans SC\", sans-serif",
"radius": "8px"
}
}
}
7. Token 构建工具链
7.1 Style Dictionary 配置
// config.js
module.exports = {
source: ['tokens/**/*.json'],
platforms: {
css: {
transformGroup: 'css',
buildPath: 'dist/css/',
files: [{
destination: 'tokens.css',
format: 'css/variables',
filter: (token) => token.isSource,
options: { outputReferences: true }
}]
},
scss: {
transformGroup: 'scss',
buildPath: 'dist/scss/',
files: [{
destination: '_tokens.scss',
format: 'scss/variables'
}]
},
typescript: {
transformGroup: 'js',
buildPath: 'dist/ts/',
files: [{
destination: 'tokens.ts',
format: 'javascript/es6'
}]
},
figma: {
transformGroup: 'js',
buildPath: 'dist/figma/',
files: [{
destination: 'figma-tokens.json',
format: 'custom/figma-variables'
}]
}
}
};
7.2 Token 同步工作流
Figma Variables
|
+---> figma-tokens 插件导出 JSON
|
v
tokens/*.json (Git 版本控制)
|
+---> Style Dictionary 构建
|
+---> CSS Custom Properties
+---> Tailwind Config
+---> TypeScript Constants
+---> 文档自动生成
8. 主题过渡动画
/* 主题切换平滑过渡 */
:root {
transition:
color var(--duration-normal) var(--easing-default),
background-color var(--duration-normal) var(--easing-default),
border-color var(--duration-normal) var(--easing-default),
box-shadow var(--duration-normal) var(--easing-default);
}
/* 或者使用 View Transitions API */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 0.3s;
}
/* JavaScript 触发 View Transition */
function setTheme(newTheme) {
if (document.startViewTransition) {
document.startViewTransition(() => {
document.documentElement.setAttribute('data-theme', newTheme);
});
} else {
document.documentElement.setAttribute('data-theme', newTheme);
}
}
9. 质量保障
9.1 Token 一致性检查
# 检查 CSS 中是否有硬编码颜色值
npx stylelint "src/**/*.css" --config '{
"rules": {
"color-no-hex": true,
"declaration-property-value-disallowed-list": {
"color": ["/^#/", "/^rgb/"],
"background-color": ["/^#/", "/^rgb/"],
"border-color": ["/^#/", "/^rgb/"]
}
}
}'
9.2 对比度自动检测
在 CI 中运行对比度检查,确保亮色和暗色主题都满足 WCAG AA:
// contrast-check.js
const themes = ['light', 'dark'];
const pairs = [
['--color-text-primary', '--color-bg-primary'],
['--color-text-secondary', '--color-bg-primary'],
['--color-text-inverse', '--color-primary'],
];
for (const theme of themes) {
for (const [fg, bg] of pairs) {
const fgColor = resolveToken(fg, theme);
const bgColor = resolveToken(bg, theme);
const ratio = contrastRatio(fgColor, bgColor);
const pass = ratio >= 4.5;
console.log(`[${pass ? 'PASS' : 'FAIL'}] ${theme}: ${fg}/${bg} = ${ratio.toFixed(2)}`);
}
}
10. 总结
| 层级 | 职责 | 更改频率 | 谁维护 |
|---|---|---|---|
| 全局 Token | 原始色板、字号、间距 | 极少 | 设计系统团队 |
| 语义 Token | 用途映射(含主题) | 偶尔 | 设计系统团队 |
| 组件 Token | 组件样式 | 较频繁 | 组件开发者 |
| 品牌覆盖 | 客户定制 | 按需 | 配置系统 |
Token 架构的核心价值:改一处,处处生效。修改语义层的 --color-primary,所有引用它的组件自动更新;切换 data-theme,整个界面瞬间变身。
Maurice | maurice_wen@proton.me