设计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