CSS 变量(官方名称是”CSS 自定义属性”,Custom Properties)是现代前端主题化与样式复用的基石。本文从 --name 与 var() 的基础语法讲起,手把手用 data-theme 与 prefers-color-scheme 实现一套可平滑切换、跟随系统的暗黑模式,并拆解 token 分层与高频踩坑,让你一次把主题系统做对。
一、什么是 CSS 自定义属性(CSS 变量)
过去我们用 Sass/Less 的变量在”编译期”替换字符串,产出的 CSS 里已经没有变量概念。而 CSS 变量是浏览器原生支持的”运行时变量”:它在页面加载后依然存在,可以被 JS 读取改写、可以被媒体查询和选择器作用域动态切换。正是这种运行时可变性,让暗黑模式、换肤、动态主题色成为可能。
命名上它以两根连字符开头(--),通过 var() 函数读取。两个连字符是强制前缀,不是风格偏好——浏览器正是靠它区分”自定义属性”和”普通属性”。
兼容性方面可以放心:所有现代浏览器(Chrome、Firefox、Safari、Edge)自 2017 年前后就已支持,生产环境无需降级方案。进阶场景还可以用 @property 给变量声明类型(如 <color>、<length>),让原本”无类型、不可动画”的自定义属性变得可被过渡和关键帧动画驱动,这在做颜色渐变主题时尤其好用。
二、为什么用 CSS 变量
2.1 一处修改,全局生效
把颜色、间距、圆角等”设计 token”集中声明在 :root,组件里只引用变量名。哪天品牌色要调整,改一行即可,不必全局搜索替换几十处硬编码色值。
2.2 运行时可改,主题切换的基石
编译期变量在打包后就固化了,而 CSS 变量在页面运行时可被 JS 或属性选择器重写。这就是暗黑模式不需要重新打包、不刷新即可切换的根本原因。它与现代网页布局(CSS Grid 与 Flexbox)配合,能构建既规整又可换肤的界面。
三、基础语法与用法
3.1 声明与读取
在 :root(代表文档根 <html>)里声明全局变量,任意元素用 var() 读取:
:root {
--color-primary: #3b82f6;
--color-bg: #ffffff;
--space-md: 16px;
--radius: 8px;
}
.btn {
background: var(--color-primary);
padding: var(--space-md);
border-radius: var(--radius);
}
3.2 作用域与继承
CSS 变量像普通属性一样会继承。在 :root 声明的是全局变量;在某个选择器里声明则只作用于它及其后代,后代可覆盖。这让”局部主题”很容易实现:
:root { --accent: blue; }
.card { --accent: green; } /* 仅 .card 及其子元素生效 */
.card .tag { color: var(--accent); } /* 读到 green */
3.3 fallback 与多层兜底
var() 第二个参数是指定变量缺失时的回退值,可多层嵌套:
color: var(--text, var(--text-default, #333));
3.4 在 JS 中读写变量
主题切换按钮就靠这几行:
// 读取
const v = getComputedStyle(document.documentElement)
.getPropertyValue('--color-primary');
// 写入(切换主题)
document.documentElement.style
.setProperty('--color-primary', '#1e40af');
3.5 用 calc() 组合变量
变量可以参与计算,这是构建间距/字号尺度系统的关键。比如定义基础单位,再派生出整套间距,改动一处就能等比缩放整个排版:
:root {
--unit: 4px;
--space-sm: calc(var(--unit) * 2); /* 8px */
--space-md: calc(var(--unit) * 4); /* 16px */
--space-lg: calc(var(--unit) * 8); /* 32px */
}
.card { padding: var(--space-md); margin-bottom: var(--space-lg); }
注意 getPropertyValue 返回的是带首尾空格的字符串,比较前记得 trim();写入变量的值也应为合法 CSS 值,否则会被浏览器静默忽略。
四、用 CSS 变量实现暗黑模式
4.1 data-theme 手动切换方案
最可控的方式:在 <html> 上挂 data-theme="dark",用属性选择器覆盖变量:
:root {
--color-bg: #ffffff;
--color-text: #1f2937;
--color-primary: #3b82f6;
}
:root[data-theme="dark"] {
--color-bg: #0f172a;
--color-text: #e2e8f0;
--color-primary: #60a5fa;
}
body {
background: var(--color-bg);
color: var(--color-text);
}
4.2 跟随系统 prefers-color-scheme
不写 JS 也能跟随系统暗色模式,适合”默认跟随、不提供手动开关”的场景:
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #0f172a;
--color-text: #e2e8f0;
}
}
4.3 二者结合的完整方案
实战中推荐”系统优先、用户可覆盖、记忆选择”:默认跟随系统,用户点切换后把选择存到 localStorage,下次加载优先用用户选择。注意脚本要放在 <head> 尽早执行,避免首屏白底闪烁(FOUC)。
<script>
const saved = localStorage.getItem('theme');
const sysDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = saved || (sysDark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
</script>
<button id="toggle">切换主题</button>
<script>
document.getElementById('toggle').onclick = () => {
const cur = document.documentElement.getAttribute('data-theme');
const next = cur === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
};
</script>
五、进阶技巧
5.1 语义化 token 分层
直接把 --blue 写进组件会让换肤再次变得困难。推荐两层:底层 primitive 存具体色值,上层 semantic 表达”用途”,组件只引用语义层。这样换成绿色主题时只改语义层映射即可。
| 层级 | 例子 | 组件该用谁 |
|---|---|---|
| Primitive | –blue-500: #3b82f6 | 不直接用 |
| Semantic | –color-primary: var(–blue-500) | 用这个 |
5.2 配合容器查询做局部主题
CSS 变量还能和 CSS 容器查询 组合:组件放进不同容器时,通过容器作用域重写变量,实现”同一个卡片在侧栏和主区用不同密度”。这是比媒体查询更精准的响应式思路。
5.3 平滑切换 transition
给会变化的属性加过渡,切换不再生硬:
body {
background: var(--color-bg);
color: var(--color-text);
transition: background-color .3s ease, color .3s ease;
}
5.4 用变量搭间距与字号尺度
除了颜色,间距、圆角、阴影、字号同样适合变量化。把它们统一收进一套”尺度(scale)”,界面会自然保持一致,也方便做无障碍适配——例如用户放大系统字号时,用变量整体抬升基准值即可。结合 Vite 前端构建,还能在构建期校验 token 是否被引用,防止出现”声明了却没人用”的死变量。
:root {
--fs-sm: 14px; --fs-md: 16px; --fs-lg: 20px;
--radius-sm: 6px; --radius-md: 10px;
--shadow-1: 0 1px 3px rgba(0,0,0,.12);
}
@media (prefers-reduced-motion: no-preference) {
body { transition: background-color .3s ease; }
}
六、性能与避坑
6.1 高频动画属性慎用 var() 重算
修改 CSS 变量可能触发重排/重绘。涉及动画时,应动画 transform、opacity 这类只触发合成层的属性,避免每帧大量重算变量导致的卡顿。原理可参考浏览器渲染:从关键渲染路径到重排重绘。
6.2 CSS 变量 ≠ Sass 变量
很多人混淆两者。下面这张表说清区别:
| 维度 | CSS 变量 | Sass 变量 |
|---|---|---|
| 生效时机 | 浏览器运行时 | 编译期替换 |
| 能否 JS 改 | 能 | 不能 |
| 作用域 | DOM 继承 | 词法作用域 |
| 典型用途 | 主题/换肤/动态值 | 构建期复用 |
6.3 覆盖顺序与优先级
CSS 变量同样遵循层叠优先级:内联 style 覆盖属性选择器,属性选择器覆盖 :root。调试主题不生效时,先用开发者工具看变量最终计算值来自哪条规则,多半是优先级被覆盖。
七、30 秒上手清单
| 步骤 | 动作 |
|---|---|
| 1 | 在 :root 声明 semantic token(背景/文字/主色) |
| 2 | 组件全部用 var() 引用,禁止硬编码色值 |
| 3 | :root[data-theme=”dark”] 覆盖一套暗色 token |
| 4 | head 内早执行脚本读 localStorage + 系统偏好 |
| 5 | 切换按钮写 setProperty + 持久化 |
| 6 | 给 body 加 transition 平滑过渡 |
把变量当”设计系统的接口”来用,主题切换、品牌换肤、暗黑模式都只是改这套接口的实现。配合 Vue3 组合式 API 等框架的响应式状态,还能把主题做成可全局注入的响应式对象,进一步提升工程化体验。




