教程

实现深色模式:开发者完整指南

阅读约 2 分钟

深色模式已从小众偏好演变为用户的预期功能。macOS、Windows、Android、iOS 等各平台的用户都可以设置全系统的深色外观,他们希望网站和应用程序能够响应这一设置。正确实现深色模式不仅仅是将白色换成黑色——它需要对颜色、对比度和用户控制采取系统化的方法。本指南将完整介绍整个实现过程,从 CSS 架构、JavaScript 切换机制,到对两套主题的全面测试。

CSS 自定义属性主题方案

在 CSS 中处理深色模式,最易维护的方式是使用自定义属性(即 CSS 变量)。不要将颜色值分散在样式表各处,而是将所有颜色定义为 :root 上的变量,然后为深色模式重新定义这些变量。组件样式只引用变量——永远不要使用原始十六进制代码。

定义浅色和深色调色板

从浅色模式调色板作为默认值开始。一个简洁的起点如下:

:root {
  /* 背景 */
  --color-bg-base:      #FFFFFF;
  --color-bg-elevated:  #F8F9FA;
  --color-bg-overlay:   #F1F3F5;

  /* 文字 */
  --color-text-primary:   #1A1A2E;
  --color-text-secondary: #4A4A6A;
  --color-text-muted:     #6C757D;

  /* 边框 */
  --color-border:         #DEE2E6;
  --color-border-strong:  #ADB5BD;

  /* 品牌/强调色 */
  --color-accent:         #3B82F6;
  --color-accent-hover:   #2563EB;

  /* 反馈色 */
  --color-success:  #22C55E;
  --color-warning:  #F59E0B;
  --color-danger:   #EF4444;
}

然后在单独的代码块中定义深色模式的覆盖值。关键在于:你并非简单地反转颜色,而是为深色表面专门选择一套独立的调色板:

[data-theme="dark"] {
  /* 背景 */
  --color-bg-base:      #0F0F17;
  --color-bg-elevated:  #1A1A2E;
  --color-bg-overlay:   #252540;

  /* 文字 */
  --color-text-primary:   #E8E8F0;
  --color-text-secondary: #A8A8C0;
  --color-text-muted:     #6A6A88;

  /* 边框 */
  --color-border:         #2E2E4A;
  --color-border-strong:  #4A4A6A;

  /* 品牌/强调色——深色背景上通常需要更浅以保证可读性 */
  --color-accent:         #60A5FA;
  --color-accent-hover:   #93C5FD;

  /* 反馈色——略微降低饱和度以避免刺眼 */
  --color-success:  #4ADE80;
  --color-warning:  #FCD34D;
  --color-danger:   #F87171;
}

注意,浅色模式的强调色 #3B82F6 在深色模式下变为 #60A5FA。色相相同,但明度提升——这是必要的,因为对比度背景已经翻转。在白色背景上通过 WCAG AA 的颜色,在近黑色背景上几乎必然失败,除非进行调整。色调生成器让你可以探索任意颜色的完整 50–950 范围,便于为每套主题选择合适的色调。

在组件中使用变量

调色板建立后,每个组件只引用变量而不使用原始值:

.card {
  background-color: var(--color-bg-elevated);
  border: 1px solid var(--color-border);
  color: var(--color-text-primary);
}

.btn-primary {
  background-color: var(--color-accent);
  color: #FFFFFF;
}

.btn-primary:hover {
  background-color: var(--color-accent-hover);
}

<html> 元素上存在 [data-theme="dark"] 属性时,所有变量同时更新,每个引用它们的组件都会改变外观——无需额外编写任何 CSS。

prefers-color-scheme 媒体查询

在用户与切换按钮交互之前,你可以使用 prefers-color-scheme 媒体查询来响应操作系统偏好。该媒体查询在操作系统设置为深色外观时触发。

@media (prefers-color-scheme: dark) {
  :root {
    --color-bg-base:      #0F0F17;
    --color-bg-elevated:  #1A1A2E;
    --color-bg-overlay:   #252540;
    --color-text-primary:   #E8E8F0;
    --color-text-secondary: #A8A8C0;
    --color-text-muted:     #6A6A88;
    --color-border:         #2E2E4A;
    --color-border-strong:  #4A4A6A;
    --color-accent:         #60A5FA;
    --color-accent-hover:   #93C5FD;
    --color-success:  #4ADE80;
    --color-warning:  #FCD34D;
    --color-danger:   #F87171;
  }
}

这种方式无需 JavaScript,不产生布局偏移,并在页面加载时立即响应用户声明的偏好。这是正确的基准实现。局限在于用户无法在你的应用内覆盖它——如果操作系统是深色,网站就是深色,无从逃脱。这也是为什么大多数生产级实现会在媒体查询之上叠加 JavaScript 切换功能。

两种方式结合使用

推荐模式是将媒体查询作为默认值,将 data-theme 属性作为显式覆盖。可以通过 CSS 特异性技巧或正确的规则顺序来实现:

/* 1. 浅色模式默认值 */
:root {
  --color-bg-base: #FFFFFF;
  /* ... */
}

/* 2. 操作系统深色模式覆盖(未设置显式偏好时) */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --color-bg-base: #0F0F17;
    /* ... */
  }
}

/* 3. 显式深色模式(用户通过 JS 切换) */
[data-theme="dark"] {
  --color-bg-base: #0F0F17;
  /* ... */
}

媒体查询中的 :not([data-theme="light"]) 选择器意味着,只有在用户没有显式选择浅色模式时,操作系统深色偏好才会生效。一旦用户切换,其显式选择优先。

JavaScript 切换机制

一个实现良好的切换需要完成三件事:立即改变当前外观、将偏好持久化到 localStorage,以及在首次绘制前从 localStorage 读取已保存的偏好。

页面加载时读取偏好

这段脚本必须在 <head> 中运行——在页面渲染之前——以防止页面加载时闪现错误的主题:

<head>
  <script>
    (function() {
      const stored = localStorage.getItem('theme');
      const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
      const theme = stored ?? (prefersDark ? 'dark' : 'light');
      document.documentElement.setAttribute('data-theme', theme);
    })();
  </script>
</head>

这段代码在任何样式应用之前立即在 <html> 上设置 data-theme。浏览器从首次绘制起就使用正确的自定义属性值——不会闪烁。

切换函数

function toggleTheme() {
  const current = document.documentElement.getAttribute('data-theme');
  const next = current === 'dark' ? 'light' : 'dark';
  document.documentElement.setAttribute('data-theme', next);
  localStorage.setItem('theme', next);
}

// 绑定到按钮
document.getElementById('theme-toggle').addEventListener('click', toggleTheme);

同步切换按钮状态

切换按钮应在视觉上反映当前模式。一种简单的方法是使用图标:

<button id="theme-toggle" aria-label="切换深色模式">
  <span class="icon-light">☀️</span>
  <span class="icon-dark">🌙</span>
</button>
[data-theme="dark"] .icon-light { display: none; }
[data-theme="dark"] .icon-dark  { display: inline; }
[data-theme="light"] .icon-light { display: inline; }
[data-theme="light"] .icon-dark  { display: none; }

由于图标可见性由与 data-theme 关联的 CSS 变量控制,每当属性变化时,按钮状态自动更新——无需额外的 JavaScript。

配色适配策略

选择深色模式颜色并不像反转浅色调色板那么简单。以下几个原则指导良好的深色配色选择。

降低对比度,而非简单翻转

纯白色文字在纯黑色背景上(#FFFFFF 叠加 #000000)技术上是最大对比度 21:1,但对长时间阅读而言认知负担过重。缩小两端的极值:正文使用接近白色的 #E8E8F0,页面背景使用深海军蓝 #0F0F17。这样可以保持充裕的对比度(仍高于 15:1),同时减少视觉疲劳。

使用对比度检查器验证深色主题中每对文字与背景的组合至少满足 WCAG AA(普通文字 4.5:1,大文字 3:1)。常见失败点包括:

  • 表单输入框中的占位文字
  • 禁用按钮标签
  • 次要元数据文字(时间戳、作者信息)
  • 无可见标签的仅图标按钮

用深色表面传达层级

在浅色模式下,层级通常通过投影来表达。在深色模式下,阴影在深色背景上几乎不可见。Material Design 3 规范引入了一种更有效的方法:更浅的表面传达更高的层级。为悬浮组件使用微妙的更浅背景:

/* 深色模式层级刻度 */
--color-bg-base:     #0F0F17;  /* 页面背景 */
--color-bg-elevated: #1A1A2E;  /* 卡片、侧边栏 */
--color-bg-overlay:  #252540;  /* 模态框、下拉菜单 */
--color-bg-tooltip:  #2E2E4A;  /* 工具提示 */

#0F0F17 作为基础,#1A1A2E 用于卡片,#252540 用于模态框——每一步在 HSL 明度上约提升 8–10%。这样无需依赖阴影即可创建清晰的视觉层级。

深色模式颜色适度降低饱和度

高饱和度颜色在深色背景上看起来刺眼,带有霓虹感。将品牌色适配到深色模式时,在提高明度的同时将饱和度降低 10–20%。与其使用鲜艳的 #22C55E 成功绿,不如选用 #4ADE80——更浅且略微降低饱和度,传达成功的同时避免视觉疲劳。

色调生成器在此处非常有用:输入你的品牌主绿色或蓝色,探索深色模式文字和图标用途的 300–400 范围,以及交互元素的 500–600 范围。

图片和媒体

白色背景的图片在深色模式下显得突兀。CSS 可以提供帮助:

/* 在深色模式下减少图片的刺眼感 */
[data-theme="dark"] img:not([src*=".svg"]) {
  filter: brightness(0.9) contrast(1.05);
}

/* 或让图片与背景略微融合 */
[data-theme="dark"] img {
  mix-blend-mode: luminosity;
  opacity: 0.9;
}

对于需要适配的 SVG 图标,使用 currentColor 作为 fill 值可以让它们自动采用当前文字颜色:

.icon { color: var(--color-text-secondary); }
<svg fill="currentColor" viewBox="0 0 24 24">...</svg>

测试两种模式

全面测试可防止深色模式问题悄悄进入生产环境。

浏览器 DevTools 模拟

Chrome 和 Firefox 都提供 DevTools 深色模式模拟,无需更改操作系统设置。在 Chrome 中:打开 DevTools,点击三点菜单,进入更多工具 → 渲染,将"模拟 CSS 媒体特性 prefers-color-scheme"设置为"dark"。这样可以并排比较两种模式。

自动化对比度测试

人工抽查容易出错。将自动化对比度审计集成到开发工作流程中。在 CI 中使用 Axe 或 Lighthouse 等工具捕获不符合 WCAG 阈值的新颜色。对比度检查器让你能够快速验证任意前景/背景组合与所有 WCAG 等级的关系——粘贴任意十六进制对,立即查看对比度。

用真实内容测试

深色模式问题经常出现在有动态内容的页面上:用户上传的图片、第三方嵌入内容、图表和地图。要针对真实的内容样本进行测试,而非仅测试孤立的组件库。

操作系统级测试

通过 DevTools 模拟验证后,要实际将操作系统设置为深色模式进行测试。prefers-color-scheme 媒体查询基于操作系统设置触发,部分浏览器在真实设置与模拟设置下的行为会略有差异。还要测试切换过渡:在页面打开时切换模式,确认不会发生布局偏移或渲染异常。

常见问题检查清单

  • 组件 CSS 中的硬编码十六进制值而非变量——搜索样式表中的原始十六进制代码并替换为变量
  • SVG 图标中硬编码的 fill="#000000"——改为 fill="currentColor"
  • 不响应 data-theme 的第三方组件——用作用域 CSS 层封装它们
  • 未设置 color-scheme 属性——在 :root 上添加 color-scheme: light dark,使浏览器原生 UI(滚动条、表单控件)也能适配
  • <head> 中缺少 <meta name="color-scheme">——添加它,使浏览器在 CSS 加载前能应用正确的背景色
<meta name="color-scheme" content="light dark">
:root {
  color-scheme: light dark;
}

这一小小的添加,可使原生滚动条、日期选择器及其他操作系统渲染的表单控件自动切换到深色变体——很多实现都会忽略这个细节。

核心要点

  • :root 上将所有颜色定义为 CSS 自定义属性,并使用 [data-theme="dark"] 为深色模式覆盖。组件样式只引用变量,一旦调色板建立,主题切换零成本。
  • 使用 prefers-color-scheme: dark 作为操作系统已设置深色外观的用户的自动默认值。在此基础上叠加带有 localStorage 持久化的 JavaScript 切换功能,供需要覆盖的用户使用。
  • 在 CSS 加载前在 <head> 中运行防闪烁脚本,避免首次绘制时出现错误主题的闪烁。
  • 深色模式颜色不是反转的浅色模式颜色——降低极端对比度,用更浅的背景传达层级,并将品牌强调色适度降低饱和度以避免霓虹感。
  • 使用对比度检查器验证每对文字与背景,使用色调生成器为两套主题的每种品牌色找到合适的色调。
  • 添加 color-scheme: light dark 及对应的 <meta> 标签,使浏览器原生 UI 元素(滚动条、输入框)也能自动切换。

相关颜色

相关品牌

相关工具