很多人第一次用 CSS 变量,只是为了"把颜色统一管理一下"。但 CSS 自定义属性真正强大的地方在于:它是运行时生效、能被级联覆盖、还能被 JS 读写的。这让它成为做主题切换(尤其是暗色模式)的天然工具。
这篇从基础讲到一套能直接用的明暗主题方案。
CSS 变量基础
CSS 自定义属性以 -- 开头,用 var() 读取:
:root {
--brand: #2563eb;
--space: 16px;
}
.button {
background: var(--brand);
padding: var(--space);
}var() 可以带兜底值,当变量没定义时使用:
.card {
color: var(--text-color, #333); /* --text-color 没定义就用 #333 */
}它和 Sass 变量有本质区别:
| CSS 变量 | Sass/Less 变量 | |
|---|---|---|
| 生效时机 | 运行时 | 编译时 |
| 能否被 JS 读写 | 能 | 不能 |
| 是否随 DOM 级联 | 是 | 否 |
| 能否在媒体查询里切换 | 能 | 静态 |
正是"运行时 + 可被 JS 修改 + 随级联"这三点,让 CSS 变量适合做主题。
关键特性:级联与作用域
CSS 变量遵循级联规则:定义在某个元素上,该元素及其后代都能用,而且后代可以覆盖。
:root {
--text: #111;
}
.dark-panel {
--text: #eee; /* 只在这个面板内部覆盖 */
}
p {
color: var(--text);
}.dark-panel 里的 <p> 是浅色字,外面的 <p> 是深色字。同一份 color: var(--text) 规则,因为变量值随作用域不同而不同。
这正是主题切换的核心机制:我们只需要在顶层切换一组变量的值,所有用到这些变量的元素会自动更新。
设计主题:语义化变量分层
做主题最常见的错误是直接把"颜色名"当变量,比如 --blue、--gray-100。一旦暗色模式下蓝色要变,语义就乱了。
更好的做法是两层变量:
- 底层调色板(palette):描述具体颜色,不带语义。
- 语义层(semantic):描述用途,引用调色板。
:root {
/* 第一层:调色板 */
--blue-500: #3b82f6;
--gray-50: #f9fafb;
--gray-900: #111827;
--white: #ffffff;
/* 第二层:语义,组件只用这一层 */
--color-bg: var(--white);
--color-text: var(--gray-900);
--color-primary: var(--blue-500);
--color-border: var(--gray-50);
}组件里只引用语义层:
body {
background: var(--color-bg);
color: var(--color-text);
}切换主题时,只改语义层的指向即可。
切换暗色:用 data 属性
在 <html> 上挂一个属性(比如 data-theme),用属性选择器覆盖语义变量:
:root {
--color-bg: #ffffff;
--color-text: #111827;
--color-primary: #2563eb;
}
:root[data-theme='dark'] {
--color-bg: #0f172a;
--color-text: #e2e8f0;
--color-primary: #60a5fa;
}JS 切换只需要一行:
function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme)
localStorage.setItem('theme', theme)
}
// 切换
setTheme(document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark')页面上所有用 var(--color-bg) / var(--color-text) 的地方,瞬间全部切换。不需要逐个改 class。
为什么用
data-theme而不是 class?两者都行。Tailwind 默认用class="dark",原生项目里data-theme更语义化、也方便扩展多套主题(data-theme="sepia")。
跟随系统偏好
操作系统有"浅色/深色"偏好,CSS 用媒体查询 prefers-color-scheme 读取。我们希望:用户没手动选过就跟随系统,选过就尊重用户选择。
CSS 里先写一套"自动跟随"的默认:
/* 用户没有手动指定(没有 data-theme)时,跟随系统 */
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) {
--color-bg: #0f172a;
--color-text: #e2e8f0;
}
}
/* 用户手动指定时,优先级更高,覆盖系统 */
:root[data-theme='dark'] { --color-bg: #0f172a; --color-text: #e2e8f0; }
:root[data-theme='light'] { --color-bg: #ffffff; --color-text: #111827; }JS 初始化时的判断逻辑:
function initTheme() {
const saved = localStorage.getItem('theme')
if (saved) {
// 用户手动选过
document.documentElement.setAttribute('data-theme', saved)
}
// 没选过就不设 data-theme,交给 prefers-color-scheme 媒体查询自动处理
}关键坑:防止刷新闪烁(FOUC)
这是暗色模式最经典的坑:页面加载时先显示浅色,JS 执行后才切成深色,白屏闪一下。
原因是读取 localStorage 设置主题的 JS 跑得太晚(在框架 hydration 之后)。解决办法是把这段逻辑做成阻塞式内联脚本,放在 <head> 最前面,在页面渲染前就把 data-theme 设好:
<head>
<script>
// 必须是同步内联,在任何样式/内容渲染前执行
(function () {
var saved = localStorage.getItem('theme')
var theme = saved || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
document.documentElement.setAttribute('data-theme', theme)
})()
</script>
<!-- 后面才是 CSS、其它资源 -->
</head>在 SSR 框架(Nuxt / Next)里,这段脚本要通过框架提供的方式注入到 HTML 头部,确保它先于内容执行。这是消除主题闪烁的标准做法。
平滑过渡,但别误伤
切换主题时给颜色加过渡,体验更好:
body {
transition: background-color 0.2s ease, color 0.2s ease;
}但要只过渡颜色相关属性,别用 transition: all——否则页面里所有元素的尺寸、位置变化都会跟着动,反而显得卡。另外,首次加载时也可能因为过渡导致一闪,可以在初始化完成后再加上过渡 class。
JS 读写 CSS 变量
CSS 变量能被 JS 动态读写,这是它比预处理器变量强的地方,适合做主题色自定义、跟随滚动的动态效果等:
const root = document.documentElement
// 读
const primary = getComputedStyle(root).getPropertyValue('--color-primary').trim()
// 写
root.style.setProperty('--color-primary', '#e11d48')比如让用户自选主题色:
colorPicker.addEventListener('input', (e) => {
document.documentElement.style.setProperty('--color-primary', e.target.value)
})一行就让全站主色变了,不需要重新编译任何 CSS。
总结
CSS 变量做主题切换的完整套路:
- 分层:底层调色板 + 语义层,组件只用语义层。
- 切换:在
<html>上用data-theme切一组语义变量,所有引用处自动更新。 - 跟随系统:
prefers-color-scheme做默认,localStorage记住用户的手动选择,后者优先。 - 防闪烁:把读取主题的逻辑做成
<head>里的同步内联脚本,先于渲染执行。 - 过渡:只过渡颜色属性,别用
transition: all。
掌握这套,你做出来的暗色模式不仅能用,而且没有刷新闪烁、能跟随系统、还能让用户自定义主题色——这就是 CSS 变量真正的价值。