用 CSS 变量轻松实现暗色模式

暗色模式几乎是现代网站的标配。实现方式有很多,而最优雅的方案之一就是用 CSS 变量:定义一套颜色变量,切换主题时只改一个属性。

这篇把完整做法记一遍,包括那个所有人第一次做都会遇到的"白屏闪一下"问题。

1. 定义主题变量

在 :root 里定义浅色,再用 [data-theme="dark"] 覆盖成暗色:

:root {
  --bg: #ffffff;
  --text: #1e2233;
  --muted: #5b6078;
  --border: #e6e8f0;
}

[data-theme="dark"] {
  --bg: #0f1120;
  --text: #eef0ff;
  --muted: #a5abc8;
  --border: #2a2f4e;
}

body {
  background: var(--bg);
  color: var(--text);
}

这样所有用 var(--xxx) 的地方都会跟着主题变,不需要写两套选择器。

关键在于变量要按用途命名,而不是按颜色命名。--bg、--text-secondary 这种名字在两个主题下都说得通;如果你定义了 --light-gray,到了暗色模式它可能是深灰,名字就开始骗人了。

2. 顺手加上 color-scheme

有个一行就能解决的细节很多人会漏:滚动条、输入框、下拉菜单这些浏览器自带控件,不会跟着你的 CSS 变量变色。加上 color-scheme 就行:

:root { color-scheme: light; }
[data-theme="dark"] { color-scheme: dark; }

加之前,暗色页面上常常挂着一条刺眼的白色滚动条。

3. 切换与持久化

用一小段 JavaScript 完成切换,并把选择存进 localStorage:

function applyTheme(theme) {
  document.documentElement.setAttribute("data-theme", theme);
  localStorage.setItem("theme", theme);
}

themeToggle.addEventListener("click", () => {
  const current = document.documentElement.getAttribute("data-theme");
  applyTheme(current === "dark" ? "light" : "dark");
});

首次访问时没有存储值,就跟随系统偏好:

const saved = localStorage.getItem("theme");
if (saved) {
  applyTheme(saved);
} else if (matchMedia("(prefers-color-scheme: dark)").matches) {
  applyTheme("dark");
}

4. 解决白屏闪烁(FOUC)

到这里功能都对了,但会有一个很明显的体验问题:暗色模式的用户每次打开页面,都会先闪一下白底再变黑。

原因是脚本通常放在 </body> 前,等它执行时浏览器已经用默认的浅色画完首屏了。这个闪烁不是性能问题,加载再快也存在。

解决办法:在 <head> 里放一小段内联脚本,在首屏绘制之前就把 data-theme 定下来。

<head>
  <link rel="stylesheet" href="style.css">

  <script>
    (function () {
      try {
        var saved = localStorage.getItem("theme");
        var dark = saved
          ? saved === "dark"
          : window.matchMedia("(prefers-color-scheme: dark)").matches;
        document.documentElement.setAttribute(
          "data-theme", dark ? "dark" : "light"
        );
      } catch (e) {}
    })();
  </script>
</head>

三个要点:

  • 必须内联,不能写成 <script src>——外部请求的等待时间足够让浏览器画出首屏。
  • 不能加 defer 或 async,那样就失去了"绘制前执行"的意义。
  • 要用 try/catch 包住。隐私模式下读 localStorage 可能直接抛异常,一旦抛了后面的脚本全不执行。

这段脚本会重复出现在每个页面的 <head> 里,无法抽成公共文件——这是为了消除闪烁必须付的代价。

5. 跟随系统主题变化

用户在系统里切换深浅色时,页面最好能实时跟上(前提是他没有手动指定过):

matchMedia("(prefers-color-scheme: dark)")
  .addEventListener("change", (e) => {
    if (!localStorage.getItem("theme")) {
      applyTheme(e.matches ? "dark" : "light");
    }
  });

这里有个设计上的取舍值得注意:如果首次访问时就把跟随系统得到的结果写进 localStorage,那它和"用户手动选择"就分不清了,之后系统主题再变,页面也不会跟随。更好的做法是只在用户点击切换按钮时才写入存储,跟随系统的情况什么都不存。

6. 几个细节

  • 过渡动画:给 body 加 transition: background 0.3s 切换会更顺滑,但要确认它不会让首次加载也跟着渐变。
  • 图片适配:暗色下给图片降一点亮度(filter: brightness(.85))能减少刺眼感。
  • 对比度:暗色模式别用纯黑纯白(#000 / #fff),对比过强反而累眼;正文对比度保持在 4.5:1 以上。
  • 尊重用户偏好:配合 prefers-reduced-motion,对动画敏感的用户关掉过渡。
  • 兼容性:CSS 变量和 prefers-color-scheme 现代浏览器全支持,不用担心。
你现在看到的这个博客就是这套方案的实战案例——试试右上角的月亮图标 🌙,然后刷新页面,不会有白屏闪烁。

小结

CSS 变量的核心价值是单一事实来源:颜色只定义一次,主题切换只改一处,维护成本极低。

而真正决定体验好坏的,是变量之外的那几个细节——color-scheme、内联的防闪烁脚本、以及"要不要写入 localStorage"这个判断。把这三点做对,暗色模式才算真的做完了。