Skip to content

颜色与暗色模式 ​

给网站加暗色模式,写一组变量、切换一个属性,半天就能“做完”。真正花时间的是上线后的一连串反馈:

  • 每次刷新页面,都先闪一下白色再变暗;
  • 页面是暗的,但滚动条、输入框、下拉框还是刺眼的白色;
  • 某些图标、按钮、第三方组件就是不变色;
  • 灰色的次要文字在暗背景上几乎看不清。

这篇讲清暗色模式的实现思路,并逐个解决这些问题。

读完这篇你会

  • 理解为什么暗色模式不能简单反色
  • 实现“跟随系统 + 手动切换 + 记住选择”的主题方案
  • 修复主题闪烁、原生控件不变色、图标写死颜色、对比度不足等问题

1. 原理:暗色模式是第二套配色,不是反色 ​

最省事的想法是把颜色取反:白变黑、黑变白。但这样会出现一连串问题:

直接反色的后果为什么
照片变成底片效果图片的颜色也被反转了
品牌色变得刺眼或发灰高饱和的颜色在暗背景上显得更亮、更“跳”
阴影看不见黑色阴影投在黑色背景上
层次消失亮色模式靠阴影区分层级,暗色下这招失效

正确的做法是为暗色模式单独设计一套语义颜色:背景用深灰而不是纯黑,文字用浅灰而不是纯白,品牌色适当提亮、降低饱和度,层级靠“越靠上的表面越亮”来区分。

选对颜色的表示方法 ​

写法例子适合
HEX#123642最常见,适合直接存色值
RGBrgb(18 54 66 / 0.5)需要透明度时
HSLhsl(195 57% 16%)手动调同色系的深浅
OKLCHoklch(0.35 0.05 220)生成整套色阶:同样的亮度值,看起来亮度也真的相同

HSL 的问题是“明度”和人眼感受不一致:hsl(60 100% 50%)(黄)和 hsl(240 100% 50%)(蓝)明度都是 50%,但黄色看起来亮得多。生成暗色模式色阶时用 OKLCH,对比度更容易控制。

2. 实现:语义变量 + 主题属性 ​

css
:root {
  --color-bg: #ffffff;
  --color-surface: #f4f6f5;
  --color-text: #13262e;
  --color-text-muted: #55676d;
  --color-border: #dde4e1;
  --color-primary: #167f84;
}

:root[data-theme='dark'] {
  --color-bg: #0f1a1f;          /* 深灰,不是纯黑 */
  --color-surface: #17252b;     /* 越“靠上”的表面越亮 */
  --color-text: #e3ece9;        /* 浅灰,不是纯白 */
  --color-text-muted: #9fb2b6;
  --color-border: #2a3b42;
  --color-primary: #4fb3b5;     /* 品牌色提亮,保证在暗背景上可读 */
}

组件只引用语义变量(var(--color-text)),不关心当前是什么主题。变量的分层设计见 CSS 变量与主题系统。

跟随系统,同时允许手动切换 ​

用户的选择通常有三种:浅色、深色、跟随系统。

css
/* 用户没有手动选择时,跟随系统 */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme='light']) {
    --color-bg: #0f1a1f;
    /* ……其余暗色变量,与上面 [data-theme='dark'] 保持一致 */
  }
}
js
function applyTheme(choice) {
  // choice: 'light' | 'dark' | 'system'
  const root = document.documentElement
  if (choice === 'system') {
    root.removeAttribute('data-theme')
  } else {
    root.dataset.theme = choice
  }
  localStorage.setItem('theme', choice)
}

暗色变量要在两个地方各写一遍(媒体查询和 data-theme)。用 Sass mixin 或构建工具生成,可以避免两边不同步。

3. 坑一:每次刷新,先闪一下白色 ​

现象:已经选了暗色模式,但每次打开或刷新页面,都会先显示一下白色页面,然后才变暗,晚上特别刺眼。

原因:主题是在应用的 JS 加载、执行之后才设置的。在那之前,浏览器已经用默认的浅色样式把页面画出来了。

修复:在 <head> 里、样式表之前,放一小段同步执行的内联脚本,在第一次绘制前就设置好主题:

html
<head>
  <script>
    ;(function () {
      try {
        var choice = localStorage.getItem('theme')
        if (choice === 'light' || choice === 'dark') {
          document.documentElement.dataset.theme = choice
        }
      } catch (e) {
        // 隐私模式等情况下 localStorage 可能不可用,此时跟随系统即可
      }
    })()
  </script>
  <link rel="stylesheet" href="/assets/app.css" />
</head>

这段脚本必须很小、不依赖任何库,也不能加 defer 或 async。服务端渲染的项目,可以把用户的选择存在 Cookie 里,服务端直接输出带 data-theme 的 HTML。

4. 坑二:滚动条、输入框、下拉框还是白的 ​

现象:页面背景已经变暗,但浏览器的滚动条、<input>、<select>、日期选择器依然是浅色的。

原因:这些原生控件由浏览器绘制,它们并不知道你的页面是暗色主题。

修复:用 color-scheme 属性告诉浏览器当前使用哪种配色:

css
:root {
  color-scheme: light dark; /* 支持两种,跟随系统 */
}
:root[data-theme='light'] { color-scheme: light; }
:root[data-theme='dark']  { color-scheme: dark; }

浏览器会据此切换滚动条、表单控件和默认背景的配色,一行代码解决一大片问题。

5. 坑三:有些图标和组件就是不变色 ​

现象:切换到暗色后,一些图标还是深色的,几乎看不见;某些按钮、表格背景保持原样。

原因:

  • SVG 图标的 fill 或 stroke 写死了颜色,比如 fill="#333";
  • 组件样式里直接写了颜色值,没有引用变量;
  • 第三方组件库有自己的一套颜色变量。

修复:

  • SVG 图标的颜色改为 currentColor,它会跟随父元素的文字颜色:
html
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
  <path fill="currentColor" d="M12 2 2 22h20L12 2z" />
</svg>
  • 搜索组件里写死的 #、rgb( 颜色,替换为语义变量;
  • 第三方组件库通常提供暗色主题或 CSS 变量,按它的文档切换,并把它的变量映射到你自己的令牌上。

6. 坑四:暗色模式下文字看不清,或者太刺眼 ​

现象:次要说明文字在暗背景上几乎看不见;或者纯白的正文在纯黑背景上,看久了眼睛很累。

原因:对比度太低看不清,对比度过高又会产生眩光。很多人在亮色模式下精心检查过对比度,暗色模式却是“凭感觉”调的。

修复:每个主题都要单独检查对比度。WCAG 建议正文与背景的对比度至少 4.5:1,大号文字至少 3:1。同时避免纯白配纯黑(对比度 21:1),使用浅灰文字配深灰背景。

在 DevTools 的 Styles 面板点击颜色色块,取色器里会直接显示当前文字与背景的对比度,并标出是否达标。

7. 坑五:阴影没了,卡片和背景糊成一片 ​

现象:亮色模式下靠阴影浮起来的卡片,在暗色模式下和背景融为一体。

原因:阴影是“更暗的颜色”,投在本来就很暗的背景上几乎不可见。

修复:暗色模式下用表面亮度表达层级:越靠上(越“浮起来”)的元素,背景色越亮;必要时加一条低对比度的边框。

css
:root[data-theme='dark'] {
  --color-surface: #17252b;        /* 卡片 */
  --color-surface-raised: #1f3038; /* 弹窗、下拉菜单,更亮一些 */
}

8. 不要只靠颜色传达信息 ​

“红色表示失败、绿色表示成功”,对红绿色弱的用户来说两者可能没有区别。暗色模式下,有些颜色的辨识度还会进一步下降。状态信息要同时配上图标或文字,比如“✓ 已保存”“✕ 保存失败”。

9. 切换时的过渡 ​

切换主题时颜色瞬间跳变会有点突兀,可以给背景和文字颜色加一个短过渡:

css
body {
  transition: background-color 200ms ease, color 200ms ease;
}

不要写 * { transition: all 200ms }:它会让页面上所有属性的变化都带上过渡,包括布局变化,既卡又容易出现意外的动画。开启了“减少动态效果”的用户,应关闭这个过渡。

排查思路 ​

  1. 模拟系统主题:DevTools → Ctrl/Cmd + Shift + P → 输入 “Rendering” → 在 Emulate CSS media feature prefers-color-scheme 里切换 light/dark,不用改系统设置。
  2. 找写死的颜色:在代码里搜索 #、rgb(、hsl(;在页面上逐个组件检查两种主题。
  3. 检查对比度:Styles 面板的取色器、Lighthouse 的无障碍检查,都会标出对比度不足的文字。
  4. 复现闪烁:在 Network 面板把网速调慢再刷新,主题闪烁会更明显。

检查清单 ​

  • [ ] 暗色主题单独设计颜色:深灰背景、浅灰文字、调整后的品牌色
  • [ ] 支持“浅色 / 深色 / 跟随系统”,并记住用户选择
  • [ ] <head> 内联脚本在首次绘制前设置主题
  • [ ] 设置了 color-scheme,原生控件跟随主题
  • [ ] 图标使用 currentColor,组件里没有写死的颜色
  • [ ] 两种主题下正文对比度都不低于 4.5:1
  • [ ] 暗色模式用表面亮度表达层级,状态信息不只靠颜色

延伸阅读 ​

为复用而记录,为理解而整理。