话题 4:多主题 / 暗黑模式最佳实践
本文是「设计系统与 Tailwind CSS 深度实践」系列的第四篇,深入探讨多主题系统的架构设计与实现方案。
一、多主题的本质:理解问题域
1.1 主题切换的几种场景
| 场景 | 描述 | 复杂度 |
|---|---|---|
| 暗黑模式 | 同一品牌的 Light / Dark 切换 | ⭐⭐ |
| 多品牌 | 品牌 A 和品牌 B 使用同一套组件,但不同视觉 | ⭐⭐⭐ |
| 暗黑 + 多品牌 | 品牌 A (Light/Dark) + 品牌 B (Light/Dark) | ⭐⭐⭐⭐ |
| 用户自定义 | 用户可调整主色、强调色等 | ⭐⭐⭐⭐⭐ |
1.2 主题切换的技术本质
主题切换 = 在不改变组件代码的前提下,切换一组设计变量的值
┌─────────────────────────────────────────────────────────┐
│ 组件代码 │
│ <Button className="bg-primary" /> │
│ │ │
│ ▼ │
│ primary 指向什么? │
│ │ │
│ ┌─────────────┼─────────────┐ │
│ ▼ ▼ ▼ │
│ Light Theme Dark Theme Brand B Theme │
│ #3B82F6 #60A5FA #10B981 │
└─────────────────────────────────────────────────────────┘关键洞察:组件只关心"语义"(primary),不关心"值"(#3B82F6)。主题切换就是切换语义到值的映射。
二、主题切换的四种实现方式
2.1 方式对比
| 方式 | 原理 | 运行时切换 | 性能 | 推荐场景 |
|---|---|---|---|---|
| CSS 变量切换 | 修改 CSS 变量值 | ✅ | ⭐⭐⭐⭐⭐ | 大多数场景 |
| Class 切换 | 切换根元素 class | ✅ | ⭐⭐⭐⭐⭐ | 与 Tailwind 配合 |
| 多套 CSS 文件 | 加载不同 CSS 文件 | ⚠️ 需刷新 | ⭐⭐⭐⭐ | 品牌完全隔离 |
| JS 主题对象 | ThemeProvider 传递 | ✅ | ⭐⭐⭐ | CSS-in-JS |
2.2 推荐方案:CSS 变量 + Class 切换
这是 Tailwind 生态的标准方案,也是我推荐你使用的方案。
核心思路:
- 用 CSS 变量定义语义 Token
- 用不同的 class(如
.dark、.brand-b)定义不同的变量值 - 切换根元素的 class 来切换主题
css
/* 主题变量定义 */
:root {
--color-bg: #FFFFFF;
--color-text: #111827;
--color-primary: #3B82F6;
}
.dark {
--color-bg: #0F172A;
--color-text: #F8FAFC;
--color-primary: #60A5FA;
}jsx
// 切换主题
document.documentElement.classList.toggle('dark');三、暗黑模式完整实现
3.1 Tailwind 配置
javascript
// tailwind.config.js
module.exports = {
darkMode: 'class', // 关键:使用 class 策略
theme: {
colors: {
// 语义颜色通过 CSS 变量
background: 'var(--color-bg)',
foreground: 'var(--color-text)',
primary: {
DEFAULT: 'var(--color-primary)',
foreground: 'var(--color-primary-foreground)',
},
muted: {
DEFAULT: 'var(--color-muted)',
foreground: 'var(--color-muted-foreground)',
},
border: 'var(--color-border)',
ring: 'var(--color-ring)',
// ... 其他语义颜色
}
}
}3.2 CSS 变量定义
css
/* globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
/* ========== Light Theme ========== */
/* 背景 */
--color-bg: 255 255 255; /* #FFFFFF */
--color-bg-secondary: 249 250 251; /* #F9FAFB */
/* 文字 */
--color-text: 17 24 39; /* #111827 */
--color-text-muted: 107 114 128; /* #6B7280 */
/* 品牌/主色 */
--color-primary: 59 130 246; /* #3B82F6 */
--color-primary-foreground: 255 255 255;
/* 静音/次要 */
--color-muted: 243 244 246; /* #F3F4F6 */
--color-muted-foreground: 107 114 128;
/* 边框 */
--color-border: 229 231 235; /* #E5E7EB */
/* 焦点环 */
--color-ring: 59 130 246;
/* 状态色 */
--color-success: 34 197 94;
--color-warning: 234 179 8;
--color-error: 239 68 68;
}
.dark {
/* ========== Dark Theme ========== */
--color-bg: 15 23 42; /* #0F172A */
--color-bg-secondary: 30 41 59; /* #1E293B */
--color-text: 248 250 252; /* #F8FAFC */
--color-text-muted: 148 163 184; /* #94A3B8 */
--color-primary: 96 165 250; /* #60A5FA */
--color-primary-foreground: 15 23 42;
--color-muted: 51 65 85; /* #334155 */
--color-muted-foreground: 148 163 184;
--color-border: 51 65 85;
--color-ring: 96 165 250;
--color-success: 74 222 128;
--color-warning: 250 204 21;
--color-error: 248 113 113;
}
}为什么用 RGB 值而不是十六进制?
为了支持 Tailwind 的透明度修饰符:
jsx
// 如果用十六进制,无法这样写
<div className="bg-primary/50" /> // 50% 透明度
// 需要在 Tailwind config 中这样配置
primary: 'rgb(var(--color-primary) / <alpha-value>)',完整的支持透明度的配置:
javascript
// tailwind.config.js
module.exports = {
theme: {
colors: {
background: 'rgb(var(--color-bg) / <alpha-value>)',
foreground: 'rgb(var(--color-text) / <alpha-value>)',
primary: {
DEFAULT: 'rgb(var(--color-primary) / <alpha-value>)',
foreground: 'rgb(var(--color-primary-foreground) / <alpha-value>)',
},
muted: {
DEFAULT: 'rgb(var(--color-muted) / <alpha-value>)',
foreground: 'rgb(var(--color-muted-foreground) / <alpha-value>)',
},
border: 'rgb(var(--color-border) / <alpha-value>)',
}
}
}3.3 主题切换 Hook
typescript
// hooks/useTheme.ts
import { useEffect, useState } from 'react';
type Theme = 'light' | 'dark' | 'system';
export function useTheme() {
const [theme, setTheme] = useState<Theme>(() => {
// 从 localStorage 读取,默认跟随系统
if (typeof window !== 'undefined') {
return (localStorage.getItem('theme') as Theme) || 'system';
}
return 'system';
});
useEffect(() => {
const root = document.documentElement;
const applyTheme = (newTheme: Theme) => {
if (newTheme === 'dark') {
root.classList.add('dark');
} else if (newTheme === 'light') {
root.classList.remove('dark');
} else {
// system: 跟随系统偏好
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
root.classList.toggle('dark', systemDark);
}
};
applyTheme(theme);
localStorage.setItem('theme', theme);
// 监听系统主题变化
if (theme === 'system') {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e: MediaQueryListEvent) => {
root.classList.toggle('dark', e.matches);
};
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}
}, [theme]);
return { theme, setTheme };
}3.4 主题切换组件
tsx
// components/ThemeToggle.tsx
import { useTheme } from '@/hooks/useTheme';
const themes = [
{ value: 'light', label: '浅色', icon: '☀️' },
{ value: 'dark', label: '深色', icon: '🌙' },
{ value: 'system', label: '系统', icon: '💻' },
] as const;
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<div className="flex gap-1 p-1 bg-muted rounded-lg">
{themes.map(({ value, label, icon }) => (
<button
key={value}
onClick={() => setTheme(value)}
className={`
px-3 py-1.5 rounded-md text-sm font-medium transition-colors
${theme === value
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}
`}
>
<span className="mr-1">{icon}</span>
{label}
</button>
))}
</div>
);
}3.5 防止闪烁(FOUC)
在 SSR/SSG 场景下,页面加载时可能会闪烁。解决方案是在 <head> 中内联一段脚本:
html
<!-- 在 Next.js 中,放在 _document.tsx 或 layout.tsx -->
<head>
<script dangerouslySetInnerHTML={{
__html: `
(function() {
const theme = localStorage.getItem('theme') || 'system';
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (theme === 'dark' || (theme === 'system' && systemDark)) {
document.documentElement.classList.add('dark');
}
})();
`
}} />
</head>四、多品牌主题实现
4.1 场景说明
假设你的组件库需要支持两个品牌:
- Brand A:蓝色系,科技感
- Brand B:绿色系,自然感
每个品牌都需要 Light 和 Dark 两种模式。
4.2 架构设计
主题矩阵:
┌──────────────┬──────────────┬──────────────┐
│ │ Light │ Dark │
├──────────────┼──────────────┼──────────────┤
│ Brand A │ A-Light │ A-Dark │
├──────────────┼──────────────┼──────────────┤
│ Brand B │ B-Light │ B-Dark │
└──────────────┴──────────────┴──────────────┘
实现策略:品牌 class + 模式 class 组合
<html class="brand-a dark"> → Brand A Dark
<html class="brand-b"> → Brand B Light
<html class="brand-a"> → Brand A Light
<html class="brand-b dark"> → Brand B Dark4.3 CSS 变量组织
css
/* globals.css */
@layer base {
/* ================================================
Brand A (默认品牌)
================================================ */
:root,
.brand-a {
/* Light Mode */
--color-primary: 59 130 246; /* Blue-500 */
--color-primary-hover: 37 99 235; /* Blue-600 */
--color-primary-foreground: 255 255 255;
--color-accent: 99 102 241; /* Indigo-500 */
--color-bg: 255 255 255;
--color-bg-secondary: 248 250 252;
--color-text: 15 23 42;
--color-text-muted: 100 116 139;
--color-border: 226 232 240;
}
.brand-a.dark,
:root.dark {
/* Brand A Dark Mode */
--color-primary: 96 165 250; /* Blue-400 */
--color-primary-hover: 147 197 253;
--color-primary-foreground: 15 23 42;
--color-accent: 129 140 248;
--color-bg: 15 23 42;
--color-bg-secondary: 30 41 59;
--color-text: 248 250 252;
--color-text-muted: 148 163 184;
--color-border: 51 65 85;
}
/* ================================================
Brand B
================================================ */
.brand-b {
/* Light Mode */
--color-primary: 16 185 129; /* Emerald-500 */
--color-primary-hover: 5 150 105; /* Emerald-600 */
--color-primary-foreground: 255 255 255;
--color-accent: 20 184 166; /* Teal-500 */
--color-bg: 255 255 255;
--color-bg-secondary: 240 253 250;
--color-text: 17 24 39;
--color-text-muted: 107 114 128;
--color-border: 209 250 229;
}
.brand-b.dark {
/* Brand B Dark Mode */
--color-primary: 52 211 153; /* Emerald-400 */
--color-primary-hover: 110 231 183;
--color-primary-foreground: 17 24 39;
--color-accent: 45 212 191;
--color-bg: 6 78 59; /* 深绿背景 */
--color-bg-secondary: 4 120 87;
--color-text: 236 253 245;
--color-text-muted: 167 243 208;
--color-border: 5 150 105;
}
}4.4 主题切换 Hook(多品牌版)
typescript
// hooks/useTheme.ts
import { useEffect, useState } from 'react';
type ColorMode = 'light' | 'dark' | 'system';
type Brand = 'brand-a' | 'brand-b';
interface ThemeConfig {
colorMode: ColorMode;
brand: Brand;
}
const DEFAULT_THEME: ThemeConfig = {
colorMode: 'system',
brand: 'brand-a',
};
export function useTheme() {
const [config, setConfig] = useState<ThemeConfig>(() => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('theme-config');
return saved ? JSON.parse(saved) : DEFAULT_THEME;
}
return DEFAULT_THEME;
});
useEffect(() => {
const root = document.documentElement;
// 应用品牌
root.classList.remove('brand-a', 'brand-b');
root.classList.add(config.brand);
// 应用色彩模式
const applyColorMode = (mode: ColorMode) => {
if (mode === 'dark') {
root.classList.add('dark');
} else if (mode === 'light') {
root.classList.remove('dark');
} else {
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
root.classList.toggle('dark', systemDark);
}
};
applyColorMode(config.colorMode);
localStorage.setItem('theme-config', JSON.stringify(config));
// 监听系统主题变化
if (config.colorMode === 'system') {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e: MediaQueryListEvent) => {
root.classList.toggle('dark', e.matches);
};
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}
}, [config]);
const setColorMode = (colorMode: ColorMode) => {
setConfig(prev => ({ ...prev, colorMode }));
};
const setBrand = (brand: Brand) => {
setConfig(prev => ({ ...prev, brand }));
};
return {
colorMode: config.colorMode,
brand: config.brand,
setColorMode,
setBrand,
};
}4.5 多品牌切换组件
tsx
// components/ThemePanel.tsx
import { useTheme } from '@/hooks/useTheme';
export function ThemePanel() {
const { colorMode, brand, setColorMode, setBrand } = useTheme();
return (
<div className="p-4 bg-background border border-border rounded-lg space-y-4">
{/* 品牌切换 */}
<div>
<label className="block text-sm font-medium text-foreground mb-2">
品牌主题
</label>
<div className="flex gap-2">
<button
onClick={() => setBrand('brand-a')}
className={`
flex-1 px-4 py-2 rounded-md text-sm font-medium transition-all
${brand === 'brand-a'
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80'
}
`}
>
Brand A (蓝色)
</button>
<button
onClick={() => setBrand('brand-b')}
className={`
flex-1 px-4 py-2 rounded-md text-sm font-medium transition-all
${brand === 'brand-b'
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80'
}
`}
>
Brand B (绿色)
</button>
</div>
</div>
{/* 色彩模式切换 */}
<div>
<label className="block text-sm font-medium text-foreground mb-2">
色彩模式
</label>
<div className="flex gap-2">
{[
{ value: 'light', label: '浅色' },
{ value: 'dark', label: '深色' },
{ value: 'system', label: '跟随系统' },
].map(({ value, label }) => (
<button
key={value}
onClick={() => setColorMode(value as any)}
className={`
flex-1 px-3 py-2 rounded-md text-sm font-medium transition-all
${colorMode === value
? 'bg-foreground text-background'
: 'bg-muted text-muted-foreground hover:bg-muted/80'
}
`}
>
{label}
</button>
))}
</div>
</div>
</div>
);
}五、Token 文件组织:最佳实践
5.1 推荐目录结构
src/
├── styles/
│ ├── tokens/
│ │ ├── primitive.css # 原始值(调色板、刻度)
│ │ ├── semantic-light.css # 语义层 - Light
│ │ ├── semantic-dark.css # 语义层 - Dark
│ │ ├── brand-a.css # 品牌 A 覆盖
│ │ └── brand-b.css # 品牌 B 覆盖
│ └── globals.css # 入口,导入所有 token
├── components/
└── tailwind.config.js5.2 分文件组织示例
css
/* styles/tokens/primitive.css */
/* 调色板 - 所有主题共享 */
:root {
/* Blue palette */
--palette-blue-50: 239 246 255;
--palette-blue-100: 219 234 254;
--palette-blue-500: 59 130 246;
--palette-blue-600: 37 99 235;
/* Green palette */
--palette-green-50: 240 253 244;
--palette-green-500: 34 197 94;
--palette-green-600: 22 163 74;
/* Gray palette */
--palette-gray-50: 249 250 251;
--palette-gray-100: 243 244 246;
--palette-gray-500: 107 114 128;
--palette-gray-900: 17 24 39;
/* Spacing scale */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-6: 1.5rem;
--space-8: 2rem;
}css
/* styles/tokens/semantic-light.css */
:root {
/* 语义颜色 - Light 模式 */
--color-bg: var(--palette-gray-50);
--color-bg-secondary: 255 255 255;
--color-text: var(--palette-gray-900);
--color-text-muted: var(--palette-gray-500);
--color-border: var(--palette-gray-200);
}css
/* styles/tokens/semantic-dark.css */
.dark {
/* 语义颜色 - Dark 模式 */
--color-bg: 15 23 42;
--color-bg-secondary: 30 41 59;
--color-text: 248 250 252;
--color-text-muted: 148 163 184;
--color-border: 51 65 85;
}css
/* styles/tokens/brand-a.css */
:root,
.brand-a {
--color-primary: var(--palette-blue-500);
--color-primary-hover: var(--palette-blue-600);
--color-primary-foreground: 255 255 255;
}
.brand-a.dark {
--color-primary: 96 165 250;
--color-primary-hover: 147 197 253;
--color-primary-foreground: 15 23 42;
}css
/* styles/tokens/brand-b.css */
.brand-b {
--color-primary: var(--palette-green-500);
--color-primary-hover: var(--palette-green-600);
--color-primary-foreground: 255 255 255;
}
.brand-b.dark {
--color-primary: 74 222 128;
--color-primary-hover: 134 239 172;
--color-primary-foreground: 17 24 39;
}css
/* styles/globals.css */
@import './tokens/primitive.css';
@import './tokens/semantic-light.css';
@import './tokens/semantic-dark.css';
@import './tokens/brand-a.css';
@import './tokens/brand-b.css';
@tailwind base;
@tailwind components;
@tailwind utilities;六、运行时切换 vs 构建时生成
6.1 两种策略对比
| 维度 | 运行时切换 | 构建时生成 |
|---|---|---|
| 实现 | CSS 变量 + class 切换 | 多套 CSS 文件 |
| 切换速度 | 即时 | 需重新加载 |
| 首屏性能 | 稍大(包含所有主题变量) | 更小(只加载当前主题) |
| 适用场景 | 用户可切换主题 | 品牌完全隔离 |
| 维护成本 | 低 | 高 |
6.2 我的建议
对于你的场景(个人组件库 + 多主题),选择运行时切换。
原因:
- 你需要用户能在运行时切换主题
- CSS 变量的性能影响可以忽略
- 维护成本低,一套代码多套主题
什么时候用构建时生成?
- 多个完全独立的产品(不需要运行时切换)
- 对首屏性能有极致要求
- 安全考虑(不希望其他品牌的样式出现在代码中)
七、高级技巧
7.1 CSS 变量的响应式覆盖
css
:root {
--page-padding: 1rem;
}
@media (min-width: 768px) {
:root {
--page-padding: 2rem;
}
}
@media (min-width: 1024px) {
:root {
--page-padding: 3rem;
}
}7.2 组件级主题覆盖
jsx
// 局部覆盖主题(例如:深色卡片在浅色页面中)
function DarkCard({ children }) {
return (
<div
className="dark bg-background text-foreground p-4 rounded-lg"
style=\{\{
// 或者用内联变量覆盖
'--color-bg': '15 23 42',
'--color-text': '248 250 252',
\}\}
>
{children}
</div>
);
}7.3 主题过渡动画
css
/* 全局主题切换动画 */
:root {
color-scheme: light;
}
.dark {
color-scheme: dark;
}
/* 平滑过渡 */
*,
*::before,
*::after {
transition: background-color 0.2s ease,
border-color 0.2s ease,
color 0.2s ease;
}
/* 排除不需要过渡的元素 */
.no-transition,
.no-transition *,
.no-transition *::before,
.no-transition *::after {
transition: none !important;
}7.4 检测用户系统主题偏好
typescript
// 检测用户是否偏好暗色
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
// 检测用户是否偏好减少动画
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// 检测用户是否偏好高对比度
const prefersContrast = window.matchMedia('(prefers-contrast: more)').matches;八、本轮小结
核心架构
多主题实现架构:
┌─────────────────────────────────────────────────────────┐
│ tailwind.config.js │
│ colors: { primary: 'rgb(var(--color-primary))' }│
└───────────────────────────┬─────────────────────────────┘
│ 引用
▼
┌─────────────────────────────────────────────────────────┐
│ CSS 变量层 │
│ :root { --color-primary: 59 130 246 } │
│ .dark { --color-primary: 96 165 250 } │
│ .brand-b { --color-primary: 34 197 94 } │
└───────────────────────────┬─────────────────────────────┘
│ 切换
▼
┌─────────────────────────────────────────────────────────┐
│ HTML 根元素 │
│ <html class="brand-a dark"> │
│ 通过 JS 动态切换 class │
└─────────────────────────────────────────────────────────┘关键实践
- 用 CSS 变量存储语义 Token,用 class 切换变量值
- 使用 RGB 格式以支持 Tailwind 的透明度修饰符
- 品牌 + 模式双维度:
.brand-a.dark组合使用 - 防止 FOUC:在
<head>中内联主题检测脚本 - 分文件组织:primitive / semantic / brand 分离
文件清单
你需要创建的文件:
tailwind.config.js- 引用 CSS 变量styles/globals.css- 定义所有主题的 CSS 变量hooks/useTheme.ts- 主题切换逻辑components/ThemeToggle.tsx- 主题切换 UI
本轮思考问题
在进入下一个话题(工程化落地方案)之前:
你的多主题需求具体是哪种? 纯暗黑模式?还是需要多品牌?
你的项目是否需要 SSR(如 Next.js)? 这会影响防闪烁方案的实现。
这套 CSS 变量 + class 切换的方案,你觉得复杂度可接受吗?