返回首页

用 CSS 自定义属性做深浅色主题

2026-08-24 · 阅读约 7 分钟

深色模式现在算是基础配置了。实现方式有好几种,我最后固定下来的是「CSS 自定义属性 + 语义化命名」这一套,改一次变量整站生效,不用在每个组件里写两遍颜色。记一下具体做法和几个坑。

先定义语义,再定义颜色

最容易犯的错是把变量命名成颜色本身:

/* ❌ 深色模式下这些名字全是谎言 */
:root {
  --white: #ffffff;
  --gray-800: #333333;
}

深色模式下 --white 得变成近黑色,名字就彻底失去意义了。正确的做法是按用途命名——这个颜色是干什么的,而不是它长什么样:

:root {
  --bg:      #fbfaf8;   /* 页面底色 */
  --panel:   #ffffff;   /* 卡片、浮层的表面 */
  --ink:     #1c1b19;   /* 主文字 */
  --ink-2:   #55524d;   /* 次级文字 */
  --ink-3:   #8a857e;   /* 辅助信息、占位 */
  --line:    #e7e3dd;   /* 分隔线、边框 */
  --accent:  #b1553a;   /* 强调色 */
}

这样切换主题时只需要重新赋值,组件里的 color: var(--ink-2) 一个字都不用改。层级命名(ink / ink-2 / ink-3)也顺带把文字对比度的层次固定了下来,写页面时不会随手挑一个中间灰。

跟随系统

最简单的一档是只跟随系统设置:

@media (prefers-color-scheme: dark) {
  :root {
    --bg:     #0f0e0d;
    --panel:  #191817;
    --ink:    #f0ede8;
    --ink-2:  #a9a49c;
    --ink-3:  #6f6a63;
    --line:   #282624;
    --accent: #e08258;
  }
}

纯静态站点用这一档就够了,零 JS。别忘了同时声明:

:root { color-scheme: light dark; }

它会让浏览器把表单控件、滚动条、默认背景一起切成深色。少了这一行,页面本身是深色的,但滚动条还是亮白的一条,很出戏。

加上手动切换

想让用户能覆盖系统设置,就需要三种状态:跟随系统、强制浅色、强制深色。用一个 data-theme 属性来表达:

/* 1. 浅色作为基准,写在裸 :root 上 */
:root { --bg: #fbfaf8; --ink: #1c1b19; /* … */ }

/* 2. 系统深色,但排除掉用户强制浅色的情况 */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --bg: #0f0e0d; --ink: #f0ede8; /* … */
  }
}

/* 3. 用户强制深色,优先级最高 */
:root[data-theme="dark"] {
  --bg: #0f0e0d; --ink: #f0ede8; /* … */
}

第 2 条里的 :not([data-theme="light"]) 是关键。少了它,系统是深色、用户手动选浅色时,两条规则会打架。

深色的值写了两遍,看着冗余,但这是让三种状态都正确的最短写法。想去重可以把深色变量抽成一个 @mixin(预处理器)或者用 light-dark() 函数——后者现在浏览器支持已经不错了:

:root {
  color-scheme: light dark;
  --bg:  light-dark(#fbfaf8, #0f0e0d);
  --ink: light-dark(#1c1b19, #f0ede8);
}

light-dark() 依赖 color-scheme 生效,手动切换时改 color-scheme: lightdark 即可,写法干净很多。

刷新闪白

如果主题偏好存在 localStorage,而读取它的脚本放在页面底部或用了 defer,就会出现经典的一闪:浏览器先按默认样式画了一帧浅色,脚本才把属性改成 dark。

解决办法是在 <head> 里放一段同步执行的内联脚本,赶在首次绘制之前把属性写上:

<script>
  try {
    var t = localStorage.getItem('theme');
    if (t) document.documentElement.dataset.theme = t;
  } catch (e) {}
</script>

这段必须内联、必须同步、必须在 head 里,三个条件缺一不可。外链脚本会引入网络往返,defer 会推到解析之后,都来不及。try/catch 是因为隐私模式下访问 localStorage 可能直接抛异常。

切换时别让整页做过渡

如果全站给颜色加了 transition,点一下主题切换按钮,页面上每个元素都会同时开始一段颜色补间,看起来像糊了一层。做法是切换的瞬间临时禁用过渡:

function setTheme(next) {
  var root = document.documentElement;
  root.classList.add('no-transition');
  root.dataset.theme = next;
  // 强制一次样式计算,确保新颜色已应用
  void root.offsetWidth;
  root.classList.remove('no-transition');
}
.no-transition, .no-transition * {
  transition: none !important;
}

中间那句 void root.offsetWidth 是在强制同步刷新样式,少了它浏览器会把两次 class 改动合并,等于什么都没做。

深色不是把颜色反过来

几个实际调下来的经验:

顺手检查一下对比度

WCAG 的门槛是正文 4.5:1、大号文字 3:1。辅助文字(我这里的 --ink-3)最容易踩线,因为设计上就想让它「淡一点」。深浅两套都要各测一遍——浅色下达标的灰,深色下同样的灰度差可能就不够了。DevTools 的取色器会直接标出对比度和是否通过,改的时候顺手看一眼就行。