用 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"这个判断。把这三点做对,暗色模式才算真的做完了。