暗黑模式与主题系统

暗色模式已经从"锦上添花"变成了用户的基本期望——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 配置要点

配置项推荐值说明
attributeclass&lt;html&gt; 上设置 class="dark"
defaultThemesystem默认跟随系统
enableSystemtrue支持系统偏好检测
disableTransitionOnChangetrue切换时禁用过渡(避免全局闪烁)
storageKeythemelocalStorage 的 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-whitebg-background
text-blacktext-foreground
bg-blue-500bg-primary
text-gray-500text-muted-foreground
border-gray-200border-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),可以:

  1. 选择基础色调(Zinc、Slate、Stone、Gray、Neutral)
  2. 选择主色(多种预设)
  3. 调整圆角大小
  4. 预览亮色/暗色效果
  5. 复制 CSS 变量代码

4.4 组件级暗色适配

shadcn/ui 组件已经内置了暗色模式支持,但自定义组件需要注意:

原则说明
使用语义 Tokenbg-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 无法读取
CookieSSR 可读取增加请求头大小
数据库跨设备同步需要登录
URL 参数可分享不持久

推荐组合:Cookie(SSR 无闪烁)+ 数据库(登录用户跨设备同步)

6. 暗色模式的设计细节

6.1 不是简单的"反色"

❌ 简单反色✅ 专业暗色设计
白变黑深灰而非纯黑
黑变白略灰白而非纯白
彩色不变降低饱和度、提高亮度
阴影不变暗色模式减少或去掉阴影
图片不变降低亮度或添加叠加层

6.2 暗色模式下的特殊处理

元素亮色模式暗色模式处理
卡片阴影区分层级背景色区分层级(更亮 = 更高)
图片正常降低亮度 10-20%
图标深色浅色(或使用 currentColor
代码块亮色主题暗色主题
表格条纹背景调整条纹对比度
分割线border-gray-200border-gray-800

6.3 elevation(高度)系统

Material Design 的暗色模式核心概念——海拔越高,表面越亮:

层级亮色模式暗色模式用途
0(基础)白色最深灰页面背景
1白色 + 浅阴影稍亮灰卡片
2白色 + 中阴影更亮灰弹窗
3白色 + 深阴影最亮灰对话框

7. 图片与媒体适配

7.1 暗色模式图片策略

策略方法适用场景
CSS 降低亮度brightness(0.85)照片、图片
双图片&lt;picture&gt; + prefers-color-schemeLogo、插图
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 自动化