CSS 变量与主题切换:本站的深浅色是怎么做的

深浅色主题现在几乎是标配。用 CSS 变量做,整套配色只需要一份定义,切换时改一个属性即可。

一、定义变量

css
:root {
  --bg: #f7f8fa;
  --panel: #ffffff;
  --text: #1b1f24;
  --muted: #737b85;
  --border: #e3e7ec;
  --accent: #3b6cf6;
}

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

要点:

  • 变量名以 -- 开头,用 var(--name) 取值;
  • 定义在 :root 上是全局的,也可以定义在某个元素上做局部覆盖;
  • 变量是继承的:子元素能拿到父元素的值,这正好用来做主题。

二、切换主题:覆盖变量

不需要写两套 CSS,只需要在切换时重新定义同一批变量

css
:root { /* 浅色 */ }

[data-theme="dark"] {
  --bg: #0c1016;
  --panel: #151b23;
  --text: #e7edf3;
  --muted: #8b949e;
  --border: #242c36;
  --accent: #58a6ff;
}
js
document.documentElement.setAttribute("data-theme", "dark");

一行 JS 就切换了整个站点的配色。这就是 CSS 变量相对 Sass/Less 变量的核心优势:运行时可变。

三、带默认值与继承覆盖

css
var(--accent, #3b6cf6)     /* 变量未定义时用默认值 */

/* 局部覆盖:某个卡片用不同强调色 */
.card-special { --accent: #e0483d; }

四、跟随系统偏好

css
@media (prefers-color-scheme: dark) {
  :root:not([data-theme]) {
    --bg: #0c1016;
    /* ... 深色值 */
  }
}

:not([data-theme]) 保证用户手动选择后,系统偏好不再覆盖。

JS 侧也能读:

js
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;

// 监听系统主题变化
window.matchMedia("(prefers-color-scheme: dark)")
  .addEventListener("change", e => console.log(e.matches ? "dark" : "light"));

五、记住用户选择

js
const KEY = "baize-theme";

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

// 读取:用户选过就用选过的,否则跟随系统
let saved = null;
try { saved = localStorage.getItem(KEY); } catch (e) {}
if (!saved) {
  saved = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
applyTheme(saved);

// 保存
btn.addEventListener("click", () => {
  const next = document.documentElement.getAttribute("data-theme") === "dark" ? "light" : "dark";
  applyTheme(next);
  try { localStorage.setItem(KEY, next); } catch (e) {}
});

try/catch 是必要的:file:// 协议下或隐私模式中,localStorage 可能直接抛异常。本站要支持双击打开,所以必须包起来。

六、闪烁问题(FOUC)

问题:页面已经用浅色渲染完了,JS 才执行,主题突然跳一下。

解法:<head> 里用一小段内联脚本,在渲染前就设好属性

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

这段脚本必须内联且同步,放在 CSS 之后、body 之前。它是全站唯一一段阻塞渲染的 JS,代价很小。

七、过渡动画要克制

切换主题时加过渡会更顺滑:

css
body {
  transition: background-color .25s ease, color .25s ease;
}

别给所有元素加 transition: all——会导致切换时满屏元素都在动,很卡。只给背景和文字加即可。

另外注意:首屏加载时如果元素有 transition,会看到一次「从默认色过渡到主题色」的动画。解决办法是给 html 加个标记,加载完成后再启用过渡。

八、别忘了这些地方

做主题时容易漏掉的:

css
::selection { background: var(--accent); color: #fff; }

/* 滚动条 */
::-webkit-scrollbar { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--border-strong); }

/* 表单控件 */
input, textarea {
  background: var(--bg-soft);
  color: var(--text);
  border: 1px solid var(--border);
}

/* 图片在深色下的处理 */
[data-theme="dark"] img { opacity: .9; }

还有 theme-color(移动端浏览器地址栏颜色):

html
<meta name="theme-color" content="#0c1016">

九、本站的变量清单

css
:root {
  /* 颜色 */
  --bg: ...;  --bg-soft: ...;  --panel: ...;
  --text: ...; --text-soft: ...; --muted: ...;
  --border: ...; --border-strong: ...;
  --accent: ...; --accent-2: ...; --accent-soft: ...;

  /* 阴影 */
  --shadow-sm: ...; --shadow: ...;

  /* 尺寸 */
  --radius: 14px; --radius-sm: 9px;

  /* 代码高亮(深浅色各一套) */
  --tok-keyword: ...; --tok-string: ...; --tok-comment: ...;

  /* 字体 */
  --font: ...; --mono: ...;
}

把「会随主题变的东西」都做成变量,切换时只需维护一份深色覆盖块。本站的代码高亮配色也是这么做的。

十、color-mix 进一步简化

现代浏览器支持 color-mix,可以基于变量派生颜色:

css
background: color-mix(in srgb, var(--accent) 12%, transparent);
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));

好处是派生色自动跟随主题,不用在深色块里再定义一遍。

bash
# 兼容性:Chrome 111+ / Safari 16.2+ / Firefox 113+
# 2026 年已经可以放心用了

主题切换的本质就一句:把所有颜色抽成变量,切换时覆盖这一组变量。