暗黑模式与主题系统
暗色模式已经从"锦上添花"变成了用户的基本期望——macOS、iOS、Android、Windows 都内置了系统级暗色模式。但正确实现暗色模式远比看起来复杂:SSR 水合闪烁、系统/用户偏好同步、组件库主题集成、多品牌主题切换。本章系统讲解 Next.js App Router 中的主题系统设计,从底层 CSS 变量到 next-themes 到 shadcn/ui 主题定制。
1. 暗色模式的技术挑战
1.1 为什么暗色模式不简单
| 挑战 | 说明 |
|---|---|
| SSR 闪烁(FOUC) | 服务端不知道用户偏好,先渲染亮色再切暗色 |
| 系统偏好同步 | 需要监听 prefers-color-scheme 变化 |
| 用户手动选择 | 覆盖系统偏好,需要持久化到 Cookie/localStorage |
| 三态切换 | 亮色 / 暗色 / 跟随系统 |
| 组件库适配 | 每个组件都需要暗色样式 |
| 图片/图表适配 | 亮色图片在暗色背景上刺眼 |
| 邮件/嵌入 | 嵌入内容不受你的主题控制 |
1.2 闪烁问题的根因
SSR 渲染(服务端)
├── 服务端不知道用户偏好
├── 默认渲染亮色 HTML
└── 发送到浏览器
浏览器接收 HTML
├── 显示亮色页面 ← 用户看到白色闪烁
├── JS 加载完成
├── 读取 localStorage/Cookie → 发现用户偏好暗色
├── 切换到暗色 ← 页面从白变黑
└── 闪烁完成
用户体验:白 → 黑,持续 100-500ms
2. next-themes
2.1 为什么选择 next-themes
next-themes 是 Next.js 暗色模式的事实标准库——它解决了上述所有挑战:
| 能力 | 说明 |
|---|---|
| 零闪烁 | 在 HTML 渲染前注入脚本设置 class |
| 三态切换 | light / dark / system |
| 系统偏好同步 | 自动监听 prefers-color-scheme |
| 持久化 | 自动存储到 localStorage |
| SSR 兼容 | 使用 Cookie 传递主题到服务端 |
| 多标签同步 | 一个标签切换,其他标签同步 |
| 体积极小 | ~2KB gzipped |
2.2 零闪烁的实现原理
next-themes 的核心技巧——在 <html> 上通过阻塞脚本注入主题 class:
1. <head> 中插入一段内联 <script>(阻塞渲染)
2. 脚本读取 localStorage 中的用户偏好
3. 没有用户偏好则检测系统偏好
4. 立即在 <html> 上设置 class="dark" 或 class="light"
5. 浏览器开始渲染 → 已经是正确的主题
→ 零闪烁
这段脚本必须是阻塞的(非 async/defer)——它必须在浏览器渲染任何内容之前执行。
2.3 配置要点
| 配置项 | 推荐值 | 说明 |
|---|---|---|
attribute | class | 在 <html> 上设置 class="dark" |
defaultTheme | system | 默认跟随系统 |
enableSystem | true | 支持系统偏好检测 |
disableTransitionOnChange | true | 切换时禁用过渡(避免全局闪烁) |
storageKey | theme | localStorage 的 key |
2.4 与 App Router 的集成
关键问题:ThemeProvider 必须是 Client Component(使用了 useState),但 layout.tsx 是 Server Component。
解决方案——创建独立的 Provider 组件:
app/layout.tsx (Server Component)
└── <Providers> (Client Component, 'use client')
└── <ThemeProvider>
└── {children}
2.5 服务端获取主题
next-themes v0.4+ 支持通过 Cookie 传递主题到服务端——让 SSR 直接渲染正确的主题:
用户切换主题 → next-themes 设置 Cookie
↓
下次请求 → 服务端读取 Cookie → 渲染对应主题
↓
完全消除闪烁(连阻塞脚本都不需要)
3. CSS 变量主题系统
3.1 设计原则
主题系统的核心是语义化颜色 Token——不是"蓝色"而是"主色",不是"灰色"而是"背景色":
| ❌ 硬编码颜色 | ✅ 语义化 Token |
|---|---|
bg-white | bg-background |
text-black | text-foreground |
bg-blue-500 | bg-primary |
text-gray-500 | text-muted-foreground |
border-gray-200 | border-border |
3.2 Token 体系设计
| Token 类别 | 变量名 | 用途 |
|---|---|---|
| 背景 | --background, --card, --popover | 页面/卡片/弹窗背景 |
| 前景 | --foreground, --card-foreground | 文字颜色 |
| 主色 | --primary, --primary-foreground | 主要按钮/链接 |
| 次色 | --secondary, --secondary-foreground | 次要按钮 |
| 强调 | --accent, --accent-foreground | 悬停/选中状态 |
| 破坏 | --destructive, --destructive-foreground | 删除/危险操作 |
| 静默 | --muted, --muted-foreground | 辅助文字/禁用 |
| 边框 | --border, --input, --ring | 边框/输入框/焦点 |
3.3 亮色/暗色变量对
:root {
--background: oklch(0.98 0 0);
--foreground: oklch(0.15 0 0);
--primary: oklch(0.55 0.15 250);
--primary-foreground: oklch(0.98 0 0);
--muted: oklch(0.93 0 0);
--muted-foreground: oklch(0.45 0 0);
--border: oklch(0.88 0 0);
--ring: oklch(0.55 0.15 250);
--radius: 0.5rem;
}
.dark {
--background: oklch(0.12 0 0);
--foreground: oklch(0.93 0 0);
--primary: oklch(0.65 0.15 250);
--primary-foreground: oklch(0.12 0 0);
--muted: oklch(0.2 0 0);
--muted-foreground: oklch(0.6 0 0);
--border: oklch(0.25 0 0);
--ring: oklch(0.65 0.15 250);
}3.4 颜色对比度
无障碍标准要求文字和背景之间有足够的对比度:
| 标准 | 对比度 | 适用 |
|---|---|---|
| WCAG AA | ≥ 4.5:1(正文) | 最低要求 |
| WCAG AA | ≥ 3:1(大字) | 标题 |
| WCAG AAA | ≥ 7:1(正文) | 最佳 |
暗色模式的常见对比度陷阱:
- 纯黑背景(
#000)配白字,对比度太高,长时间阅读刺眼 - 推荐深灰(
oklch(0.12 0 0)≈#1c1c1c)而非纯黑 - 暗色模式的灰色层级更难区分——需要仔细调整
4. shadcn/ui 主题定制
4.1 shadcn/ui 的主题架构
shadcn/ui 的主题完全基于 CSS 变量——所有组件都使用语义化 Token:
CSS 变量(:root / .dark)
↓
Tailwind @theme 映射到工具类
↓
shadcn/ui 组件使用工具类
↓
改变量 = 改所有组件的外观
4.2 自定义 shadcn/ui 主题
| 定制级别 | 方法 | 效果 |
|---|---|---|
| 颜色 | 修改 CSS 变量 | 整体色调 |
| 圆角 | 修改 --radius | 所有组件圆角 |
| 字体 | 修改 --font-family | 全局字体 |
| 间距 | 修改组件源码 | 特定组件间距 |
| 组件结构 | 修改组件源码 | 完全自定义 |
4.3 shadcn/ui 主题生成器
shadcn/ui 提供了在线主题生成器(ui.shadcn.com/themes),可以:
- 选择基础色调(Zinc、Slate、Stone、Gray、Neutral)
- 选择主色(多种预设)
- 调整圆角大小
- 预览亮色/暗色效果
- 复制 CSS 变量代码
4.4 组件级暗色适配
shadcn/ui 组件已经内置了暗色模式支持,但自定义组件需要注意:
| 原则 | 说明 |
|---|---|
| 使用语义 Token | bg-background 而非 bg-white |
| 避免硬编码颜色 | 所有颜色都走变量 |
| 测试暗色模式 | 每个组件都要在暗色下检查 |
| 检查边框可见性 | 亮色下可见的边框在暗色下可能消失 |
| 检查阴影 | 暗色模式下阴影效果不同 |
5. 多主题系统
5.1 超越亮/暗:品牌主题
对于 SaaS 产品或白标方案,可能需要多种品牌主题:
| 场景 | 主题数量 | 切换方式 |
|---|---|---|
| 个人偏好 | 2(亮/暗) | 用户手动 |
| 品牌定制 | 3-5 | 管理后台配置 |
| 白标 SaaS | 无限 | 每个客户独立 |
5.2 动态主题加载
对于白标 SaaS,主题不能写死在 CSS 中——需要动态加载:
用户访问 tenant-a.example.com
↓
服务端查询 tenant-a 的品牌配置
↓
返回 HTML,<style> 中注入该租户的 CSS 变量
↓
所有组件自动使用该租户的品牌色
5.3 主题持久化策略
| 方式 | 优势 | 劣势 |
|---|---|---|
| localStorage | 简单 | SSR 无法读取 |
| Cookie | SSR 可读取 | 增加请求头大小 |
| 数据库 | 跨设备同步 | 需要登录 |
| URL 参数 | 可分享 | 不持久 |
推荐组合:Cookie(SSR 无闪烁)+ 数据库(登录用户跨设备同步)
6. 暗色模式的设计细节
6.1 不是简单的"反色"
| ❌ 简单反色 | ✅ 专业暗色设计 |
|---|---|
| 白变黑 | 深灰而非纯黑 |
| 黑变白 | 略灰白而非纯白 |
| 彩色不变 | 降低饱和度、提高亮度 |
| 阴影不变 | 暗色模式减少或去掉阴影 |
| 图片不变 | 降低亮度或添加叠加层 |
6.2 暗色模式下的特殊处理
| 元素 | 亮色模式 | 暗色模式处理 |
|---|---|---|
| 卡片 | 阴影区分层级 | 背景色区分层级(更亮 = 更高) |
| 图片 | 正常 | 降低亮度 10-20% |
| 图标 | 深色 | 浅色(或使用 currentColor) |
| 代码块 | 亮色主题 | 暗色主题 |
| 表格 | 条纹背景 | 调整条纹对比度 |
| 分割线 | border-gray-200 | border-gray-800 |
6.3 elevation(高度)系统
Material Design 的暗色模式核心概念——海拔越高,表面越亮:
| 层级 | 亮色模式 | 暗色模式 | 用途 |
|---|---|---|---|
| 0(基础) | 白色 | 最深灰 | 页面背景 |
| 1 | 白色 + 浅阴影 | 稍亮灰 | 卡片 |
| 2 | 白色 + 中阴影 | 更亮灰 | 弹窗 |
| 3 | 白色 + 深阴影 | 最亮灰 | 对话框 |
7. 图片与媒体适配
7.1 暗色模式图片策略
| 策略 | 方法 | 适用场景 |
|---|---|---|
| CSS 降低亮度 | brightness(0.85) | 照片、图片 |
| 双图片 | <picture> + prefers-color-scheme | Logo、插图 |
| SVG 换色 | currentColor 或 CSS 变量 | 图标、图表 |
| 透明背景 | PNG/SVG 透明底 | Logo |
| 叠加层 | 半透明深色叠加 | 背景图 |
7.2 Favicon 暗色适配
现代浏览器支持 SVG Favicon,可以用 CSS media query 适配暗色模式:
<svg>
<style>
path { fill: #000; }
@media (prefers-color-scheme: dark) {
path { fill: #fff; }
}
</style>
<path d="..."/>
</svg>8. 测试与验证
8.1 主题测试清单
| 检查项 | 说明 |
|---|---|
| ✅ 零闪烁 | 首次加载无白屏闪烁 |
| ✅ 系统偏好 | 跟随系统切换 |
| ✅ 手动切换 | 三态正常工作 |
| ✅ 持久化 | 刷新后保持选择 |
| ✅ 多标签同步 | 一个标签切换,其他跟随 |
| ✅ 对比度 | 满足 WCAG AA |
| ✅ 所有组件 | 每个组件在暗色下正常 |
| ✅ 表单元素 | 输入框、选择器暗色适配 |
| ✅ 第三方嵌入 | 代码块、地图等 |
| ✅ 图片 | 不刺眼 |
8.2 自动化测试
使用 Playwright 测试暗色模式:
1. 设置 prefers-color-scheme: dark
2. 截图对比
3. 检查 CSS 变量值
4. 验证切换按钮功能
5. 验证持久化
本章小结
- 核心挑战:SSR 闪烁、三态切换、组件适配、对比度、图片处理
- next-themes:零闪烁实现(阻塞脚本注入 class),Cookie 传递给服务端
- CSS 变量:语义化 Token 体系(background/foreground/primary/muted),亮暗独立定义
- shadcn/ui:全量基于 CSS 变量,改变量即改所有组件,在线主题生成器
- 暗色设计:不是简单反色——深灰替代纯黑、降低饱和度、高度系统替代阴影
- 多主题:Cookie + 数据库持久化,白标 SaaS 动态注入 CSS 变量
- 图片适配:CSS brightness、双图片、SVG currentColor、透明背景
- 测试:零闪烁 + 对比度 + 全组件覆盖 + Playwright 自动化