设计Token与主题切换实现
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 | [email protected]
深度加工(NotebookLM 生成)
基于本文内容生成的 PPT 大纲、博客摘要、短视频脚本与 Deep Dive 播客,用于多场景复用
PPT 大纲(5-8 张幻灯片) 点击展开
设计Token与主题切换实现 — ppt
这是一份基于您提供的文章内容生成的 PPT 大纲,共包含 5 张幻灯片,涵盖了设计 Token 的概念、架构、暗色主题、工程实现以及自动化工具链的核心要点:
幻灯片 1:什么是设计 Token?
- 核心定义:设计 Token 是将设计决策编码为数据的方法,被定义为“设计决策的最小可传递单元” [1]。
- 多维信息:它不是简单的变量或常量,每一个 Token 都携带了名称、值、类型和作用域四个维度的信息 [1]。
- 解决硬编码痛点:相比于传统直接写入颜色值的方式,Token 方法通过语义化变量(如
--color-primary)来管理样式,使设计意图得以追溯 [1]。 - 涵盖范围广:分类全面,包含颜色、字体、间距、圆角、阴影、动画、边框以及不透明度等多个界面的视觉维度 [1]。
幻灯片 2:三层 Token 架构解析
- 全局层(Global Tokens):定义基础的原始值(如具体色号
--blue-500或字号16px),不携带任何语义信息 [1]。 - 语义层(Semantic Tokens):携带具体用途信息,通过引用全局层 Token 实现管理(例如背景色、文本色、状态色等) [1]。
- 组件层(Component Tokens):最具体的层级,直接引用语义层,专门针对按钮、卡片、输入框等具体组件定义样式 [1, 2]。
- 架构价值:通过分离职责实现了“改一处,处处生效”,无论修改品牌色还是切换整体风格,都能自动更新所有相关组件 [3]。
幻灯片 3:暗色主题的核心策略与规则
- 实现本质:暗色主题不是简单的“反转颜色”,其核心策略是在“语义层”切换底层颜色变量的映射关系 [2]。
- 技术实现:通过 CSS 中赋予
[data-theme="dark"]属性或使用媒体查询@media (prefers-color-scheme: dark)重写主题变量 [2]。 - 设计优化规则:暗背景下高饱和色刺眼,需要降低10-20%的饱和度;同时需要加深阴影表现,并避免使用对比过强的纯黑色 [2]。
- 细节打磨:暗色模式下的图片需使用滤镜降低亮度(如
filter: brightness(0.85)),卡片样式也可由看不见的阴影转为使用边框进行层级区分 [2]。
幻灯片 4:主题切换的工程化实践
- 状态逻辑与持久化:在前端框架(如 React)中可以构建
useThemeHook 管理“浅色、深色、跟随系统”三种状态,并监听系统偏好变化 [2]。 - 避免页面闪烁(FOUC):在文档的渲染前注入自执行脚本(或结合 Next.js 中的 Cookie)提前设置好当前的主题属性 [4]。
- Tailwind 集成:在
tailwind.config.js中将 CSS 变量映射为 Tailwind 的原生类名,使开发体验更加顺畅 [4]。 - 平滑过渡动画:主题切换时,可借助 CSS 属性过渡 (
transition) 或使用较新的View Transitions API实现色彩无缝变化 [3]。
幻灯片 5:动态定制与全链路质量保障
- 动态品牌定制:支持运行时主题覆盖,通过 JavaScript 修改语义 Token,即可一键加载特定的企业品牌配色及字体 [4]。
- Token 同步工作流:支持从 Figma 导出变量 JSON 并通过 Git 进行版本控制,形成规范的设计-开发闭环 [3]。
- 多平台构建:借助 Style Dictionary 等工具,可将 Token 统一配置文件自动编译并转化为 CSS、SCSS、TypeScript 常量等多平台产物 [3, 4]。
- 一致性与可用性检测:通过 Stylelint 拦截 CSS 中的颜色硬编码,并在 CI 系统中自动化检查前景与背景颜色的对比度,确保符合视觉无障碍要求 [3]。
博客摘要 + 核心看点 点击展开
设计Token与主题切换实现 — summary
SEO 友好博客摘要
本文深度解析了如何利用 Design Token 和 CSS 自定义属性构建高效的前端主题切换与暗色模式工程架构 [1]。文章摒弃传统的硬编码方式,详细讲解了**“全局层、语义层、组件层”的三层 Token 架构设计,使设计决策成为可传递的数据单元 [1]。同时,结合 React、Next.js 与 Tailwind CSS,提供了从基础实现到防闪烁(FOUC)、品牌动态切换及对比度自动检测**的完整工程化落地指南 [2-4]。对于致力于打造现代化设计系统的前端开发者,本文提供了实现“改一处,处处生效”的最佳实践 [4]。
核心看点
- 三层 Token 架构模型:通过全局、语义与组件三层划分,实现高可维护的动态样式管理 [1]。
- 科学的暗黑模式:拒绝简单反转,基于语义层映射,精细调整暗背景下的亮度与阴影 [1, 2]。
- 完善的工程化落地:无缝集成 Tailwind 与前端框架,涵盖防闪烁机制及工具链自动同步 [2-4]。
60 秒短视频脚本 点击展开
设计Token与主题切换实现 — video
这是一份为您定制的 60 秒短视频脚本,严格按照您的字数和结构要求编写:
【钩子开场】(12字)
告别硬编码,实现一键换肤![1]
【核心解说】
- 它将设计决策编码为数据,分为全局、语义和组件三层架构。[1](27字)
- 暗黑模式绝非简单的颜色反转,而是在语义层切换映射关系。[2](27字)
- 其核心价值是改一处处处生效,修改语义层即可让界面瞬间变身。[3](30字)
【收束】
掌握 Token 架构,让你的前端主题管理更高效灵活!
课后巩固
与本文内容匹配的闪卡与测验,帮助巩固所学知识
延伸阅读
根据本文主题,为你推荐相关的学习资料