在 CSS 预处理器统治的十年里,“变量”一直被当作 Sass / Less 的专利。很多人至今仍然认为:变量是编译期的东西,浏览器根本不需要懂。但事实恰恰相反——CSS Custom Properties(CSS 自定义属性,习惯上称为 CSS 变量)是浏览器原生支持、参与级联、可以被 JavaScript 实时读写、能够沿 DOM 树继承的真变量。它和 Sass 变量的关系,不是替代,而是两个不同维度的工具:Sass 变量在构建时消失,CSS 变量在运行时活着。本篇 35 分钟长文,从语法基础讲到作用域与继承、回退值、@property 类型注册、设计令牌体系、主题切换、组件 API、响应式与容器查询、性能与调试,最后给出一份可以直接贴在工位上的检查清单。读完之后,你大概率会把项目里一半的 Sass 变量搬进 :root。
一、为什么浏览器需要自己的变量
先把概念理清楚。CSS 自定义属性是 CSS 规范中的一部分,任何以两个连字符开头的属性名(例如 --brand-color)都是自定义属性。它的值可以是几乎任何东西——颜色、长度、数字、字符串,甚至是一段完整的声明片段。使用时通过 var() 函数引用。
它和 Sass 变量的根本差别,可以用一句话概括:
Sass 变量是“写代码时的替换”,CSS 变量是“浏览器运行时的数据”。前者在编译后不复存在,后者会一直留在 DOM 上,参与级联、可以被继承、可以被 JS 修改、可以响应媒体查询。
预处理器变量做不到的四件事
@media 里重写变量值,所有引用点自动生效
getComputedStyle 读取、用 setProperty 写入
这四件事,恰好是现代 Web 应用最常遇到的需求:暗色模式、多品牌主题、组件可配置、拖拽实时预览、画布尺寸计算……全都是“运行时”的问题,而预处理器变量天生解决不了运行时的问题。
一个最小的例子
:root {
--brand: #2563eb;
--radius: 8px;
}
/* 使用:用 var() 引用 */
.btn {
background: var(--brand);
border-radius: var(--radius);
}
/* 覆盖:在子树里重写,只影响这一支 */
.card {
--brand: #10b981;
}
注意最后一段。.card 内部的所有 var(--brand) 都会变成绿色,包括它内部所有子元素——因为自定义属性是继承的。这个看似简单的特性,正是整套主题系统与组件 API 的地基。
命名规范:双连字符不是装饰
为什么必须写成 --foo 而不是 foo?因为 CSS 的解析器需要一种方式区分“自定义属性”和“未来的标准属性”。双连字符是规范为扩展预留的命名空间:任何以 -- 开头的属性名,浏览器都不会把它当作错误,而是原样保存下来。这也意味着你永远不会和未来的 CSS 新属性撞名——因为标准属性绝不会以两个连字符开头。
命名本身建议遵守几条约定:
- 全小写 + 连字符分隔:
--color-primary,不要用驼峰或下划线。 - 语义优先于外观:
--color-danger优于--color-red,因为换主题时红色可能变成橙色。 - 组件私有变量加前缀:
--btn-padding、--card-shadow,避免全局污染。 - 层级用连字符表达:
--color-text-muted、--space-lg。
二、语法基础:声明、引用与合法值
声明一个变量
自定义属性写在普通的声明块里,和别的属性没什么两样,规则同样适用:
--size: 16px;
--gap: 1.5rem;
--shadow: 0 4px 12px rgba(0,0,0,.12);
--font-stack: 'Noto Sans SC', sans-serif;
--empty: ; /* 合法!空值 */
}
关键点:自定义属性的值在声明阶段不做语法校验。浏览器把它当成一串“几乎任意”的 token 保存下来,直到你通过 var() 真正把它代入某个具体属性时,才进行解析。这个特性叫“延迟求值”,它既带来灵活性,也带来一些反直觉的坑(后面第八节会专门讲)。
引用一个变量
用 var() 函数引用,它接受两个参数:变量名与可选的回退值。
padding: var(--size);
margin: var(--gap, 20px); /* 找不到就用 20px */
box-shadow: var(--shadow, none);
}
var() 可以嵌套、可以拼接、可以出现在几乎任何值的位置:
--hue: 221;
--unit: 8px;
}
.panel {
/* 拼接:变量 + 单位 */
padding: calc(var(--unit) * 2);
/* 现代颜色语法:色相由变量提供 */
background: hsl(var(--hue) 80% 55%);
border-color: hsl(var(--hue) 60% 75%);
/* 变量套变量 */
--local-radius: var(--unit);
border-radius: var(--local-radius);
}
这条 hsl(var(--hue) ...) 模式值得记住。把色相抽成一个数字变量,就能用一行 JS 实现整站换色,而且是所有派生色(边框、阴影、悬停态)一起跟着变,不用逐个写颜色值。这是设计系统里最常用的技巧之一。
变量可以存什么
| 类型 | 示例 | 说明 |
|---|---|---|
| 颜色 | --c: #2563eb; |
最常用,注意主题化时保持语义命名 |
| 长度 | --gap: 1rem; |
配合 calc() 可做比例系统 |
| 纯数字 | --i: 3; |
可参与 calc()、z-index、line-height |
| 字符串 | --label: "草稿"; |
可用于 content |
| 整段值 | --shadow: 0 2px 8px #0002; |
一次替换多个 token |
| 空值 | --x: ; |
合法,常用于“有效但为空”的回退策略 |
大小写敏感,空格敏感
自定义属性名大小写敏感:--Color 和 --color 是两个完全不同的变量。同时,值里的空白会被保留,所以下面两种写法有细微差别:
--font-stack: "Segoe UI", Tahoma, sans-serif;
/* 有风险:值后面带了多余空格,拼进简写属性时可能出问题 */
--shadow: 0 2px 8px red ;
/* 安全写法:写简写属性时,让变量值保持“完整且干净” */
.a { box-shadow: var(--shadow); }
三、作用域与继承:CSS 变量真正的威力
如果 CSS 变量只是“能改的常量”,那它确实没什么了不起。真正让它区别于所有预处理器方案的,是作用域规则完全等同于普通 CSS 属性——它遵守级联、遵守继承、遵守选择器优先级。
变量沿 DOM 树向下继承
这张图说明了两件事:
- 在
.card上重写--size,只影响这个子树,页面其他地方不受任何影响。 - 没有被子孙重写的变量,会一直从祖先继承下来。
也就是说,任何一个选择器都可以成为一个“变量作用域”。这是组件化最需要的特性:组件的样式可以完全由外部注入的变量驱动,而组件自身不需要知道外部是什么。
一个可直接套用的组件 API 模式
.btn {
--btn-bg: var(--color-primary, #2563eb);
--btn-fg: white;
--btn-radius: 6px;
background: var(--btn-bg);
color: var(--btn-fg);
border-radius: var(--btn-radius);
}
/* 外部调用者:只传变量,不改样式 */
.btn.danger {
--btn-bg: #ef4444;
}
.btn.pill {
--btn-radius: 999px;
}
/* 甚至可以从行内样式注入,做一次性定制 */
<button class="btn" style="--btn-bg: #8b5cf6">紫色按钮</button>
这个模式的优雅之处在于:组件的样式规则只写一次,所有变体都通过变量注入完成。没有额外的选择器分支,没有样式重复,行内样式也不会破坏样式表的可维护性——因为行内样式只提供数据,不提供规则。
业内把这种变量称为“组件级设计令牌”或“可覆盖的私有变量”,是 Web Components 与设计系统里最常见的做法。
就地覆盖:不需要额外选择器
还有一个常被忽略的能力:你可以直接在元素的行内样式里定义变量,作用域自动限定在这个元素及其子树:
<h2>这个区块是粉色调</h2>
<p>内部所有用 var(--hue) 的地方都跟着变</p>
</section>
在需要“同一组件在同一页面出现多种配色”的场景下,这比写一堆 .theme-a、.theme-b 干净得多。
优先级:变量遵循普通级联
自定义属性的声明同样受选择器优先级约束。常见的一个坑是:
.card { --c: red; }
#special { --c: green; }
/* 对一个同时匹配三者的元素,最终生效的是 #special(优先级最高) */
另一个更隐蔽的坑:自定义属性的“继承值”优先级低于任何直接声明。也就是说,即使父元素的声明来自 ID 选择器,子元素上一条低优先级的类选择器声明也会覆盖它——因为对子元素来说,那是“自己的值”,而不是“继承来的值”。理解这一点,才能避免在调试时对着 DevTools 发懵。
循环引用会被判为无效
如果变量之间互相引用形成环,两个变量都会失效:
--a: var(--b);
--b: var(--a); /* 死循环 */
}
.x { color: var(--a, red); } /* 回退到 red */
浏览器会检测到循环并把这两个变量都视为“无效的初始值”,此时 var() 的回退值会接管。这个机制保证了页面不会因为一个变量写错而整个崩掉。
小实验:拖动滑块,实时改变一个子树的变量
下面这个演示把 --lab-hue、--lab-radius、--lab-space 三个变量定义在预览容器上,卡片内部全部通过 var() 引用。拖动滑块时,改变的只是容器上的三个变量值——但颜色、圆角、内边距会一起重算。这就是“变量驱动样式”最直观的样子。
组件预览
卡片本身没有任何硬编码颜色与间距,全部来自容器上的变量。
主要操作--lab-hue: 221;
--lab-radius: 10px;
--lab-space: 16px;
}
四、回退值与容错:写不崩的变量
var() 的第二个参数是回退值,语法如下:
规则很直接:当 --name 没有被定义,或者它的值在当前上下文里无效时,使用 fallback。
三种回退写法
color: var(--text-color, #0f172a);
/* 2. 回退到另一个变量(形成链条) */
color: var(--text-color, var(--base-color, black));
/* 3. 回退值里包含逗号:整体用一层括号包住 */
font-family: var(--font, ("Segoe UI", sans-serif));
background: var(--bg, url(a.png) center / cover no-repeat);
/* 4. 空回退:变量缺失时整个声明“什么都不做” */
box-shadow: var(--shadow, );
/* 等价于:--shadow 不存在时不输出 box-shadow */
第 4 种写法非常有用。它让你可以写一条“可选增强”的声明:如果外部提供了 --shadow,就加阴影;没提供,就完全不加,而不是被迫回退到某个具体值。
回退值的触发条件
| 情况 | 是否触发回退 | 说明 |
|---|---|---|
| 变量未定义 | 触发 | 最常见的情况 |
变量值为空(--x: ;) |
触发 | 空值被视为无效 |
| 变量存在但语法非法 | 触发 | 如 --size: 12 用在 width 上 |
| 变量循环引用 | 触发 | 环中所有变量都失效 |
| 变量有值但被继承覆盖 | 不触发 | 值只是变了,不是不存在 |
| 变量名拼写错误 | 触发 | 等同于未定义,也是排查时的头号嫌疑 |
“无效变量”的连锁反应
这是 CSS 变量最反直觉的一点,值得单独讲清楚。当 var() 替换后的结果在目标属性上非法时,整个属性会变成“无效的初始值”(invalid at computed-value time),而不是回退到该属性的初始值。
--size: 12; /* 没有单位 */
}
.box {
width: 200px;
width: var(--size); /* 解析失败 */
}
很多人的直觉是:第二条无效,那就用第一条的 200px。但实际结果是——width 变成了它的初始值 auto。因为第二条声明在被判定为无效时,浏览器采用的是“无效值在计算时”的规则:整个声明直接失效,不会回到级联中的上一条声明。
要避免这个坑,只有两条路:
- 保证变量的值始终带有正确的单位与格式。
- 用
@property给变量注册类型,让浏览器在赋值阶段就做校验与转换(见第六节)。
用回退值做“可选 API”
回退值还有一个进阶用法:把回退当成组件的“默认值”,把变量声明当成“外部覆盖点”。
/* 外部不传就用自己的默认色 */
background: var(--card-bg, #ffffff);
border-radius: var(--card-radius, 12px);
}
这种写法在需要兼容“还没有迁移到变量体系”的老代码时尤其方便:不改结构、不加选择器,只在样式表里把硬编码值换成带默认值的 var(),老页面保持原样,新页面则可以开始注入变量。
width: var(--size, 100px);而
--size: 12 没有单位——回退不会生效,宽度直接变成 auto。
--size: 12px;,或者用 @property 注册为 <length> 类型。
五、var() 能用在哪里,不能用在哪里
一句话总结:var() 只能出现在“属性值”的位置,不能出现在“属性名”“选择器”“媒体查询条件”里。
| 位置 | 能否使用 var() | 替代方案 |
|---|---|---|
属性值:color: var(--c) |
可以 | — |
简写属性的一部分:border: 1px solid var(--c) |
可以 | — |
媒体查询条件:@media (min-width: var(--bp)) |
不行 | 用 @custom-media(草案)或 JS 读取 |
| 容器查询条件 | 不行 | 需要写死或用 style() 查询 |
选择器:.item-var(--i) |
不行 | 用属性选择器 [data-i="3"] |
属性名:var(--prop): red |
不行 | 无 |
| 动画关键帧里 | 部分可以 | 详见第十节 |
content 属性 |
注意 | 需要字符串格式的变量值 |
媒体查询的“替代方案”其实很好用
虽然不能把变量写进 @media 条件里,但可以反过来——在媒体查询里修改变量的值。这是更常见的做法:
--gutter: 40px;
--columns: 3;
}
@media (max-width: 768px) {
:root {
--gutter: 16px;
--columns: 1;
}
}
.grid {
display: grid;
gap: var(--gutter);
grid-template-columns: repeat(var(--columns), 1fr);
}
repeat(var(--columns), 1fr) 是 CSS 变量最漂亮的应用之一。修改一个数字,整个网格的列数就变了,不需要重复写 grid-template-columns。
content 属性的注意事项
content 需要的是字符串(或 counter() 等特定值)。如果变量本身存的就是带引号的字符串,直接引用没问题;如果是裸文本,则需要包一层引号——但引号不能直接写在 var() 外面拼接:
:root { --label: "草稿"; }
.badge::before { content: var(--label); } /* ✔ 正确 */
/* 变量里存裸文本,想拼引号 */
:root { --label: 草稿; }
.badge::before { content: "\"" var(--label) "\""; } /* 部分浏览器支持有限 */
最稳妥的做法是让变量值自带引号,也就是第一种写法。这样在任何位置引用都不需要额外处理。
简写属性里的变量陷阱
看这段代码:
.a { font: var(--size) / 1.5 sans-serif; } /* 可能失效 */
.b { font-size: var(--size); line-height: 1.5; font-family: sans-serif; } /* 安全 */
简写属性在解析时要求值符合特定的顺序与结构,而变量替换是“文本级”的。一旦拼接结果不符合简写语法,整条声明就会失效。因此建议:在简写属性里使用变量时,让变量承载一个完整、独立的值片段(比如单独的颜色、单独的长度),而不是把多个片段拼在一起。
六、@property:给变量加上类型
默认情况下,自定义属性是“无类型”的字符串。@property 是 CSS Houdini 的一部分,允许你为变量注册一个明确的语法类型,从而获得三个额外能力:
- 类型校验:赋值不符合类型时,自动回退到初始值,而不是让整条声明失效。
- 可动画:注册为
<color>、<length>、<number>等类型的变量,可以在transition与@keyframes里平滑过渡。 - 强制初始值:变量未定义时会使用
initial-value,而不是继承。
基本语法
syntax: "<number>";
inherits: true;
initial-value: 221;
}
@property --brand-color {
syntax: "<color>";
inherits: true;
initial-value: #2563eb;
}
@property --card-radius {
syntax: "<length>";
inherits: false; /* 不继承,每个组件独立 */
initial-value: 8px;
}
三个描述符的含义:
"<length>"、"<color>"、"<number>"、"*"
false,这点和未注册变量相反
"*" 则可以不写
最实用的场景:让渐变色动起来
没有 @property 之前,CSS 无法对渐变做过渡,因为浏览器不知道两个渐变之间该如何插值。@property 通过把“角度”注册成 <angle> 解决了这个问题:
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
.glow {
--angle: 0deg;
background: conic-gradient(from var(--angle), #2563eb, #8b5cf6, #2563eb);
transition: --angle 1.2s ease;
}
.glow:hover {
--angle: 360deg;
}
transition: --angle 1.2s 这种写法之所以可行,完全依赖于前面的 @property 注册。未注册的变量在过渡里会被当作离散值,直接跳变。
让长度变量也参与过渡
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
.card {
transform: translateY(var(--lift));
box-shadow: 0 calc(var(--lift) * -1) 20px rgba(15,23,42,.12);
transition: --lift .3s cubic-bezier(.34,1.56,.64,1);
}
.card:hover { --lift: 6px; }
这段代码把“位移”和“阴影偏移”绑定到同一个变量上,两者会同步过渡。用一个变量驱动多个视觉属性,是保持视觉一致性的有效手段。
syntax 支持的常见类型
| syntax 值 | 含义 | 可动画 |
|---|---|---|
"<length>" |
长度,如 12px |
是 |
"<number>" |
纯数字 | 是 |
"<percentage>" |
百分比 | 是 |
"<color>" |
颜色 | 是 |
"<angle>" |
角度 | 是 |
"<time>" |
时间 | 是 |
"<length-percentage>" |
长度或百分比 | 是 |
"*" |
任意值(默认行为) | 否 |
什么时候该用 @property
- 需要让变量参与
transition或@keyframes时——必须用。 - 变量承载的是纯数字,需要参与
calc()运算,希望获得类型保护时。 - 希望变量不继承(每个组件独立)时,用
inherits: false。 - 纯色板、间距这类静态令牌——不必用,徒增代码量。
七、实战:用变量搭一套设计令牌
设计令牌(Design Token)是把设计决策抽象成命名数据的过程。CSS 变量是实现它的最自然载体。一套结构清晰的分层令牌,通常分三层:
--blue-500、--gray-100、--size-4。这一层通常由设计工具导出,不直接使用。--color-text、--color-surface、--color-danger、--space-section。业务代码只引用这一层,主题切换也只需要重写这一层。--btn-bg: var(--color-primary)。这是组件对外暴露的“配置接口”。完整示例
:root {
--blue-500: #2563eb;
--blue-600: #1d4ed8;
--green-500: #10b981;
--red-500: #ef4444;
--slate-900: #0f172a;
--slate-100: #f1f5f9;
/* 尺寸刻度:8px 基准 */
--size-1: 4px;
--size-2: 8px;
--size-3: 12px;
--size-4: 16px;
--size-6: 24px;
--size-8: 32px;
--size-12: 48px;
}
/* ========== 第二层:语义令牌 ========== */
:root {
--color-primary: var(--blue-500);
--color-primary-hover: var(--blue-600);
--color-success: var(--green-500);
--color-danger: var(--red-500);
--color-text: var(--slate-900);
--color-text-muted: #64748b;
--color-surface: #ffffff;
--color-border: #e2e8f0;
--space-inline: var(--size-2);
--space-block: var(--size-4);
--space-section: var(--size-12);
--radius-sm: 4px;
--radius-md: 8px;
--radius-full: 999px;
}
暗色主题:只重写语义层
--color-text: #f8fafc;
--color-text-muted: #94a3b8;
--color-surface: #0f172a;
--color-border: #1e293b;
--color-primary: #60a5fa; /* 暗底上需要更亮的蓝 */
}
请注意:只有语义层被重写,基础调色板完全不动。这正是分层的价值——主题切换的成本被压缩到十几行代码,组件样式一行都不用改。
点击体验一下主题切换
下面这块演示区域只切换了一个 data-theme 属性值。所有颜色、边框、按钮样式都通过变量自动重算。
主题切换演示
这块区域内部没有任何 !important,也没有重复的样式规则。切换主题时,只是把一组变量换成了另一组。
跟随系统偏好
:root:not([data-theme="light"]) {
--color-text: #f8fafc;
--color-surface: #0f172a;
--color-border: #1e293b;
}
}
:root:not([data-theme="light"]) 这个选择器保证:用户手动选择的主题优先级高于系统偏好。当用户点击“切换到浅色”时,HTML 上的 data-theme="light" 会让这条媒体查询失配,从而保持浅色。
这套“系统偏好 + 用户覆盖”的三态模型(跟随系统 / 强制浅色 / 强制深色)已经成为现代 Web 应用的标准做法。
八、响应式与容器查询中的变量
媒体查询里重写变量
把“断点相关的值”集中在一处管理,是变量在响应式设计里最大的价值:
--page-padding: 64px;
--card-columns: 3;
--title-size: 2.5rem;
}
@media (max-width: 1024px) {
:root { --card-columns: 2; --page-padding: 40px; }
}
@media (max-width: 640px) {
:root {
--card-columns: 1;
--page-padding: 16px;
--title-size: 1.75rem;
}
}
相比在每个组件的规则里各写一遍媒体查询,这种写法的好处是响应式逻辑集中、可读、可预测。你打开文件顶部就知道整个页面在不同尺寸下的表现。
流体尺寸:clamp() + 变量
clamp() 让尺寸可以在两个边界之间平滑变化,而变量让这些边界可以被集中管理:
--fluid-min: 1.2rem;
--fluid-max: 2.4rem;
--fluid-preferred: 1rem + 2.5vw;
}
.headline {
font-size: clamp(var(--fluid-min), var(--fluid-preferred), var(--fluid-max));
}
容器查询 + 变量的组合拳
容器查询允许组件根据“自己的宽度”而不是“视口宽度”做响应。配合变量,可以写出真正自适应的组件:
container-type: inline-size;
}
.card {
--card-pad: 24px;
--card-cols: 2;
padding: var(--card-pad);
display: grid;
grid-template-columns: repeat(var(--card-cols), 1fr);
}
@container (max-width: 400px) {
.card {
--card-pad: 14px;
--card-cols: 1;
}
}
这个组合的意义在于:响应式规则写在组件自己的样式块里,而不是散落在全局断点中。组件被放进侧边栏时会自动变成单列,被放进主区域时会自动变成双列,无需外部干预。
用变量统一间距节奏
很多项目间距混乱,根源在于“随手写数值”。用一组等比变量约束之后,节奏自然统一:
--rhythm: 8px;
}
.stack > * + * {
margin-top: calc(var(--rhythm) * 2);
}
.tight > * + * {
margin-top: calc(var(--rhythm) * 0.75);
}
.loose > * + * {
margin-top: calc(var(--rhythm) * 4);
}
只要改一个 --rhythm,整个页面的垂直节奏就会按比例缩放。这在做“紧凑 / 舒适 / 宽松”三档密度设置时非常好用。
九、变量 vs 预处理器变量:到底该用哪个
这不是一个二选一的问题。成熟的工程里,两者通常是分工协作的。点击按钮对比一下:
$brand: #2563eb;
$radius: 8px;
.btn {
background: $brand;
border-radius: $radius;
}
// 编译结果:值被写死
.btn {
background: #2563eb;
border-radius: 8px;
}
// 可以在 @each / @for / 数学函数里自由使用
@for $i from 1 through 4 {
.mt-#{$i} { margin-top: $i * 8px; }
}
分工建议
| 用途 | 推荐方案 | 原因 |
|---|---|---|
| 主题色、间距、圆角等运行时令牌 | CSS 变量 | 需要运行时切换与继承 |
| 循环生成工具类 | Sass | CSS 没有循环 |
| 颜色函数(变亮、混合) | Sass | CSS 的 color-mix() 已在普及,但生态仍偏 Sass |
| 媒体查询断点值 | Sass | 变量不能写进 @media 条件 |
| 组件对外暴露的配置项 | CSS 变量 | 需要被外部覆盖 |
| 与 JS 共享的数值 | CSS 变量 | 可以被 JS 直接读写 |
混合模式:Sass 变量指向 CSS 变量
一个常见的做法是让 Sass 变量只做“编译期的名字”,值则指向 CSS 变量:
$radius: var(--radius-md);
.btn {
background: $brand;
border-radius: $radius;
}
// 编译后:
.btn {
background: var(--color-primary);
border-radius: var(--radius-md);
}
这样业务代码仍然写 $brand,但实际生效的是运行时可变的 CSS 变量。迁移成本最低,主题能力却立刻到手。
十、性能、动画与调试
性能:变量本身很便宜,但要小心三个细节
- 改变量会触发重算:修改变量值会让所有引用它的属性重新计算样式。如果这个变量被上千个元素引用,重算范围就大。但这是必要成本,和直接改样式属性没有本质区别。
- 变量参与的属性决定重绘 / 重排:变量本身不决定性能,它驱动的属性才决定。驱动
transform、opacity走合成层;驱动width、top就会重排。 - 避免深层继承链上的频繁修改:在
:root上每秒改变量,会导致整棵树重新计算。如果只是某个小组件需要动,把变量定义在那个组件上。
transform: translateX(var(--x));变量驱动合成属性,动画流畅,不触发重排。
left: var(--x);同样是动画,却每帧触发重排,复杂页面上容易掉帧。
动画里的变量
未注册的变量无法平滑过渡,只能跳变。要让变量动起来,有两条路:
路线一:用 @property 注册类型(推荐,见第六节)。
路线二:在 @keyframes 里重新赋值变量。虽然变量本身不能平滑插值,但可以让关键帧在不同阶段用不同的变量值,配合其他可动画属性一起使用:
0% { --glow: 0px; opacity: 1; }
50% { --glow: 24px; opacity: .85; }
100% { --glow: 0px; opacity: 1; }
}
.dot {
box-shadow: 0 0 var(--glow) rgba(37,99,235,.5);
animation: pulse 2s ease-in-out infinite;
}
这里的 --glow 会在关键帧之间跳变,但因为 box-shadow 的模糊半径本身在视觉上就不敏感,加上 opacity 的平滑过渡,最终效果依然自然。
用变量驱动 SVG 与 Canvas 之外的图形
内联 SVG 可以直接读取 CSS 变量,这让图标颜色可以跟随主题:
--icon-color: currentColor;
}
.icon path {
fill: var(--icon-color);
}
.icon.danger { --icon-color: #ef4444; }
JS 读写变量
const el = document.querySelector('.card');
const hue = getComputedStyle(el)
.getPropertyValue('--hue')
.trim(); // "221"
// 写:注意第一个参数要带 --
el.style.setProperty('--hue', '340');
// 删除:回到继承值
el.style.removeProperty('--hue');
// 批量:用 cssText 或逐个 setProperty
document.documentElement.style.setProperty('--density', 'compact');
注意 getPropertyValue 返回的是字符串,数值计算前记得 parseFloat。同时,读到的值可能是空字符串——因为变量未定义。写健壮一点:
const hue = raw ? parseFloat(raw) : 221;
DevTools 里的变量调试
- Elements 面板:选中元素,右侧 Styles 面板会列出它继承到的所有自定义属性,未生效的变量名会显示为灰色。
- Computed 面板:可以看到每个属性的最终计算值,鼠标悬停时会显示
var()的展开过程。 - 颜色选择器:点击带
var()的颜色值,可以直接调色并实时写回变量——调试主题时非常高效。 - 强制状态:右键元素选择
:hover、:focus等强制状态,可以直接检查状态变量的覆盖情况。
一个常见的性能误区
“CSS 变量比直接写值慢”是一个流传很广的说法。实际上,现代浏览器的样式引擎会把变量展开过程优化得很高效,在绝大多数页面上差异小到无法测量。真正影响性能的是:变量驱动的属性是否触发了不必要的重排,以及改变量的元素是否位于继承树的根节点上。
十一、十个最常见的 CSS 变量误区
@media 条件里使用 var(),结果整条规则失效
transition 平滑过渡
:root,组件之间无法隔离
--red、--big-font,主题一换就全错
var(--x, 10px) 却期望它覆盖外部定义
--Color 和 --color 是两个东西
el.style.getPropertyValue 去取继承值,结果永远是空字符串
--c1、--c2,三个月后没人知道是什么
两个需要展开说的点
关于“全塞进 :root”:把变量全部定义在根节点上,等于放弃了 CSS 变量最大的优势——作用域。组件私有的变量应该定义在组件自己的选择器上,只有真正全局的语义令牌才放 :root。判断标准很简单:如果这个值只服务于某个组件,它就不该出现在全局。
关于“外观命名”:--red 这个名字在暗色主题下会变成谎话,因为那个位置可能被改成橙色。--color-danger 才是稳定的语义。命名约定应该描述用途,而不是外观。
--red: #ef4444;--font-16: 1rem;--c1: #2563eb;
--color-danger: #ef4444;--font-body: 1rem;--color-primary: #2563eb;
十二、兼容性与渐进增强
CSS 变量本身的浏览器支持已经非常成熟,真正需要关注的是后续出现的增强特性。
| 特性 | 支持情况 | 使用建议 |
|---|---|---|
自定义属性 + var() |
全平台 | 放心使用,是基础设施 |
回退值 var(--x, y) |
全平台 | 放心使用 |
变量参与 calc() |
全平台 | 放心使用 |
@property |
现代浏览器 | 作为增强,缺失时变量仍可正常用作静态值 |
变量参与 transition |
依赖 @property | 需要同时提供 @property 与降级样式 |
| 容器查询 + 变量 | 现代浏览器 | 老浏览器下退化为单一布局 |
hsl() 空格分隔语法 |
广泛支持 | 配合变量做色相系统很合适 |
color-mix() |
现代浏览器 | 可替代部分 Sass 颜色函数 |
渐进增强的写法
变量最大的好处是它天然可降级——先写一条静态声明,再写一条变量声明。老浏览器忽略第二条,新浏览器用第二条:
background: #ffffff; /* 降级 */
background: var(--card-bg, #ffffff); /* 增强 */
}
不过要提醒一句:不支持 CSS 变量的浏览器(如 IE11)早已不在现代前端项目的支持范围内,为它们写降级代码通常是浪费时间。上面这个模式真正的用途,是处理变量本身可能缺失的场景,而不是兼容老浏览器。
用 @supports 做特性检测
/* 支持变量的浏览器 */
}
@supports (background: paint(something)) {
/* CSS Paint API 可用 */
}
十三、检查清单与可复用基线
把全文结论压缩成一份可以贴在工位上的清单。每次新增一个变量之前扫一眼,能省下大量后期的重构时间。
- 语义优先:变量名描述用途,不描述外观。
--color-danger而不是--red。 - 分层管理:基础色板 → 语义令牌 → 组件变量 → 实例覆盖,四层不要混。
- 全局变量克制:只有真正跨组件复用的令牌才放
:root。 - 组件变量加前缀:
--btn-*、--card-*,避免命名碰撞。 - 默认值写在组件里:组件用
var(--btn-bg, #2563eb)提供兜底,外部只负责覆盖。 - 数值变量带单位:避免“无效的初始值”导致的整条声明失效。
- 需要动画就注册:参与
transition的变量必须用@property声明类型。 - 不继承就声明:组件私有变量用
inherits: false,避免意外穿透。 - 不在 @media 条件里用 var():要改就改媒体查询里的变量值。
- 不在选择器和属性名里用 var():那里不支持。
- 主题切换只重写语义层,不要碰组件样式与基础色板。
- 用 JS 读值时走 getComputedStyle,并做好空值兜底。
- 变量驱动 transform / opacity,避免驱动 layout 属性做高频动画。
- 调试时打开 Styles 面板,灰色变量名意味着没生效,先查拼写。
- 保留 Sass 做循环与颜色函数,不要把预处理器整个丢掉。
- 迁移老项目时保留原有 class,只把硬编码值换成带默认值的
var()。
/* 1. 基础色板 */
:root {
--blue-500: #2563eb;
--blue-600: #1d4ed8;
--green-500: #10b981;
--red-500: #ef4444;
--slate-900: #0f172a;
--slate-100: #f1f5f9;
}
/* 2. 语义令牌 */
:root {
--color-primary: var(--blue-500);
--color-primary-hover: var(--blue-600);
--color-success: var(--green-500);
--color-danger: var(--red-500);
--color-text: var(--slate-900);
--color-text-muted: #64748b;
--color-surface: #ffffff;
--color-border: #e2e8f0;
--space-1: 4px;
--space-2: 8px;
--space-4: 16px;
--space-8: 32px;
--radius-sm: 4px;
--radius-md: 8px;
--radius-full: 999px;
--shadow-sm: 0 1px 3px rgba(15,23,42,.08);
--shadow-md: 0 6px 18px rgba(15,23,42,.12);
}
/* 3. 暗色主题 */
[data-theme="dark"] {
--color-text: #f8fafc;
--color-text-muted: #94a3b8;
--color-surface: #0f172a;
--color-border: #1e293b;
--color-primary: #60a5fa;
}
/* 4. 可动画变量注册 */
@property --lift {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
/* 5. 组件接口 */
.btn {
--btn-bg: var(--color-primary);
--btn-fg: #ffffff;
--btn-radius: var(--radius-md);
--btn-pad-y: 8px;
--btn-pad-x: 18px;
background: var(--btn-bg);
color: var(--btn-fg);
border-radius: var(--btn-radius);
padding: var(--btn-pad-y) var(--btn-pad-x);
transform: translateY(calc(var(--lift) * -1));
box-shadow: var(--shadow-sm);
transition: --lift .25s ease, box-shadow .25s ease;
}
.btn:hover { --lift: 2px; }
/* 6. 实例覆盖 */
.btn.danger { --btn-bg: var(--color-danger); }
.btn.pill { --btn-radius: var(--radius-full); }
CSS 变量最迷人的地方在于它的“性价比”:改造成本极低,回报却覆盖了主题化、组件化、响应式、运行时交互和设计系统五个方向。它不替代预处理器,而是补上了预处理器永远无法触及的那块空白——运行时。当你第一次用一行 JS 把整站配色换掉,或者用三个变量重写整个组件的皮肤时,就会明白为什么规范委员会当初坚持把它做进浏览器,而不是留给构建工具。
所以,下次你在样式表里敲下一个十六进制颜色值之前,不妨先停半秒问自己一句:“这个值,以后会不会变?”——如果答案是“也许会”,那它就该是一个变量。