暗黑模式需结合CSS变量与data-theme类控制,媒体查询仅作初始兜底;必须同步localStorage与HTML class,所有颜色须用语义化变量,避免硬编码。

暗黑模式不能只靠 @media (prefers-color-scheme: dark)
系统级偏好只是起点,不是全部。用户手动切换时,@media (prefers-color-scheme: dark) 完全不触发——它只响应系统设置,且无法被 JS 覆盖或持久化。真正在产品里落地的暗黑模式,必须结合 CSS 变量 + 显式 class 控制(比如 html[data-theme="dark"]),媒体查询只做初始值兜底。
用 :root 定义两套变量,但别重复写死
把颜色抽成语义化变量(如 --bg-primary、--text-secondary),在 :root 里统一声明默认值;再用 html[data-theme="dark"] 和媒体查询分别覆盖:
:root {
--bg-primary: #ffffff;
--text-primary: #1a1a1a;
--border: #e0e0e0;
}
<p>@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg-primary: #121212;
--text-primary: #e0e0e0;
--border: #333;
}
}</p><p>html[data-theme="dark"] {
--bg-primary: #121212;
--text-primary: #e0e0e0;
--border: #333;
}注意两点:一是 :root:not([data-theme="light"]) 避免媒体查询和用户手动设置冲突;二是所有主题变量必须在 :root 层级定义,子选择器里重定义变量无效(CSS 自定义属性不继承,但会级联)。
data-theme 切换必须同步 localStorage 和 class
只改 HTML class 不够,刷新就丢;只存 localStorage 不改 class,样式不生效。两者必须原子性同步:
立即学习“前端免费学习笔记(深入)”;
- 读取时优先用
localStorage.getItem('theme'),无则 fallback 到matchMedia('(prefers-color-scheme: dark)').matches - 切换时用
document.documentElement.setAttribute('data-theme', 'dark')+localStorage.setItem('theme', 'dark') - 监听系统变化用
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', ...),但仅用于更新 localStorage,不直接改 class(避免覆盖用户主动选择)
组件内不要硬编码 #121212 这类值
一旦用了具体颜色值,就等于绕过主题系统。所有颜色必须来自变量,哪怕临时调试也要写 color: var(--text-primary);。常见坑包括:
- 伪元素(
::before)里漏写var() - SVG 内联样式写死
fill="#333",应改为fill="var(--icon-fill)"并在 CSS 里定义该变量 - 第三方 UI 库未适配,需用
[data-theme="dark"] .lib-button单独覆盖,不能指望它自动响应prefers-color-scheme
最麻烦的其实是过渡动画和阴影——box-shadow 和 background 的明暗反差太大时,光靠变量不够,得额外加一层中间变量控制强度,比如 --shadow-depth: 0.5;,再组合计算:box-shadow: 0 2px calc(var(--shadow-depth) * 4px) rgba(0,0,0,0.15);。这种细节媒体查询管不了,只能靠 JS 或更细粒度的变量设计撑住。


















