CSS变量(Custom Properties)

张玥 2026年9月20日 阅读时间 35分钟
CSS Custom Properties 设计系统 主题化 @property
前端开发技巧之CSS变量(Custom Properties)

在 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 修改、可以响应媒体查询。

预处理器变量做不到的四件事

运行时修改 Sass 变量编译后就消失了,用户点一下按钮换主题?做不到
作用域继承 Sass 变量只在编译期按嵌套规则作用,无法沿 DOM 树动态继承
媒体查询响应 在 @media 里重写变量值,所有引用点自动生效
JS 读写 用 getComputedStyle 读取、用 setProperty 写入

这四件事,恰好是现代 Web 应用最常遇到的需求:暗色模式、多品牌主题、组件可配置、拖拽实时预览、画布尺寸计算……全都是“运行时”的问题,而预处理器变量天生解决不了运行时的问题。

一个最小的例子

/* 声明:在 :root 上定义,全局可用 */
: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。

二、语法基础:声明、引用与合法值

声明一个变量

自定义属性写在普通的声明块里,和别的属性没什么两样,规则同样适用:

:root {
  --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() 函数引用,它接受两个参数:变量名与可选的回退值。

.box {
  padding: var(--size);
  margin: var(--gap, 20px); /* 找不到就用 20px */
  box-shadow: var(--shadow, none);
}

var() 可以嵌套、可以拼接、可以出现在几乎任何值的位置:

:root {
  --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-family 上没问题 */
--font-stack: "Segoe UI", Tahoma, sans-serif;

/* 有风险:值后面带了多余空格,拼进简写属性时可能出问题 */
--shadow: 0 2px 8px red ;

/* 安全写法:写简写属性时,让变量值保持“完整且干净” */
.a { box-shadow: var(--shadow); }

三、作用域与继承:CSS 变量真正的威力

如果 CSS 变量只是“能改的常量”,那它确实没什么了不起。真正让它区别于所有预处理器方案的,是作用域规则完全等同于普通 CSS 属性——它遵守级联、遵守继承、遵守选择器优先级。

变量沿 DOM 树向下继承

:root --size: 16px --brand: #2563eb
.card --size: 12px (--brand 仍继承为 #2563eb)
.card .inner --size 继承为 12px --brand 继承为 #2563eb

这张图说明了两件事:

  • 在 .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 与设计系统里最常见的做法。

就地覆盖:不需要额外选择器

还有一个常被忽略的能力:你可以直接在元素的行内样式里定义变量,作用域自动限定在这个元素及其子树:

<section style="--hue: 340">
  <h2>这个区块是粉色调</h2>
  <p>内部所有用 var(--hue) 的地方都跟着变</p>
</section>

在需要“同一组件在同一页面出现多种配色”的场景下,这比写一堆 .theme-a、.theme-b 干净得多。

优先级:变量遵循普通级联

自定义属性的声明同样受选择器优先级约束。常见的一个坑是:

:root { --c: blue; }
.card { --c: red; }
#special { --c: green; }

/* 对一个同时匹配三者的元素,最终生效的是 #special(优先级最高) */

另一个更隐蔽的坑:自定义属性的“继承值”优先级低于任何直接声明。也就是说,即使父元素的声明来自 ID 选择器,子元素上一条低优先级的类选择器声明也会覆盖它——因为对子元素来说,那是“自己的值”,而不是“继承来的值”。理解这一点,才能避免在调试时对着 DevTools 发懵。

循环引用会被判为无效

如果变量之间互相引用形成环,两个变量都会失效:

:root {
  --a: var(--b);
  --b: var(--a); /* 死循环 */
}

.x { color: var(--a, red); } /* 回退到 red */

浏览器会检测到循环并把这两个变量都视为“无效的初始值”,此时 var() 的回退值会接管。这个机制保证了页面不会因为一个变量写错而整个崩掉。

小实验:拖动滑块,实时改变一个子树的变量

下面这个演示把 --lab-hue、--lab-radius、--lab-space 三个变量定义在预览容器上,卡片内部全部通过 var() 引用。拖动滑块时,改变的只是容器上的三个变量值——但颜色、圆角、内边距会一起重算。这就是“变量驱动样式”最直观的样子。

CUSTOM PROPERTY

组件预览

卡片本身没有任何硬编码颜色与间距,全部来自容器上的变量。

主要操作
.var-lab-preview {
  --lab-hue: 221;
  --lab-radius: 10px;
  --lab-space: 16px;
}

四、回退值与容错:写不崩的变量

var() 的第二个参数是回退值,语法如下:

var(--name, fallback)

规则很直接:当 --name 没有被定义,或者它的值在当前上下文里无效时,使用 fallback。

三种回退写法

/* 1. 简单回退 */
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),而不是回退到该属性的初始值。

:root {
  --size: 12; /* 没有单位 */
}

.box {
  width: 200px;
  width: var(--size); /* 解析失败 */
}

很多人的直觉是:第二条无效,那就用第一条的 200px。但实际结果是——width 变成了它的初始值 auto。因为第二条声明在被判定为无效时,浏览器采用的是“无效值在计算时”的规则:整个声明直接失效,不会回到级联中的上一条声明。

要避免这个坑,只有两条路:

  • 保证变量的值始终带有正确的单位与格式。
  • 用 @property 给变量注册类型,让浏览器在赋值阶段就做校验与转换(见第六节)。

用回退值做“可选 API”

回退值还有一个进阶用法:把回退当成组件的“默认值”,把变量声明当成“外部覆盖点”。

.card {
  /* 外部不传就用自己的默认色 */
  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 条件里,但可以反过来——在媒体查询里修改变量的值。这是更常见的做法:

:root {
  --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) "\""; } /* 部分浏览器支持有限 */

最稳妥的做法是让变量值自带引号,也就是第一种写法。这样在任何位置引用都不需要额外处理。

简写属性里的变量陷阱

看这段代码:

:root { --size: 20px; }

.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,而不是继承。

基本语法

@property --brand-hue {
  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;
}

三个描述符的含义:

syntax 类型字符串,如 "<length>"、"<color>"、"<number>"、"*"
inherits 是否沿 DOM 树继承,默认 false,这点和未注册变量相反
initial-value 未定义时的初始值;如果 syntax 是 "*" 则可以不写

最实用的场景:让渐变色动起来

没有 @property 之前,CSS 无法对渐变做过渡,因为浏览器不知道两个渐变之间该如何插值。@property 通过把“角度”注册成 <angle> 解决了这个问题:

@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 注册。未注册的变量在过渡里会被当作离散值,直接跳变。

让长度变量也参与过渡

@property --lift {
  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 变量是实现它的最自然载体。一套结构清晰的分层令牌,通常分三层:

1
基础层
Raw Values
最原始的调色板与尺寸,只描述“是什么”,不含任何语义:--blue-500、--gray-100、--size-4。这一层通常由设计工具导出,不直接使用。
2
语义层
Semantic
描述“用在哪”:--color-text、--color-surface、--color-danger、--space-section。业务代码只引用这一层,主题切换也只需要重写这一层。
3
组件层
Component
组件自己的私有变量,默认值指向语义层,允许外部覆盖:--btn-bg: var(--color-primary)。这是组件对外暴露的“配置接口”。
4
实例层
Instance
通过行内样式或作用域选择器,对某一个具体实例做一次性覆盖。优先级最高,绝不写进样式表。

完整示例

/* ========== 第一层:基础调色板 ========== */
: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;
}

暗色主题:只重写语义层

[data-theme="dark"] {
  --color-text: #f8fafc;
  --color-text-muted: #94a3b8;
  --color-surface: #0f172a;
  --color-border: #1e293b;
  --color-primary: #60a5fa; /* 暗底上需要更亮的蓝 */
}

请注意:只有语义层被重写,基础调色板完全不动。这正是分层的价值——主题切换的成本被压缩到十几行代码,组件样式一行都不用改。

点击体验一下主题切换

下面这块演示区域只切换了一个 data-theme 属性值。所有颜色、边框、按钮样式都通过变量自动重算。

[data-theme="light"]

主题切换演示

这块区域内部没有任何 !important,也没有重复的样式规则。切换主题时,只是把一组变量换成了另一组。

跟随系统偏好

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --color-text: #f8fafc;
    --color-surface: #0f172a;
    --color-border: #1e293b;
  }
}

:root:not([data-theme="light"]) 这个选择器保证:用户手动选择的主题优先级高于系统偏好。当用户点击“切换到浅色”时,HTML 上的 data-theme="light" 会让这条媒体查询失配,从而保持浅色。

这套“系统偏好 + 用户覆盖”的三态模型(跟随系统 / 强制浅色 / 强制深色)已经成为现代 Web 应用的标准做法。

八、响应式与容器查询中的变量

媒体查询里重写变量

把“断点相关的值”集中在一处管理,是变量在响应式设计里最大的价值:

:root {
  --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() 让尺寸可以在两个边界之间平滑变化,而变量让这些边界可以被集中管理:

:root {
  --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));
}

容器查询 + 变量的组合拳

容器查询允许组件根据“自己的宽度”而不是“视口宽度”做响应。配合变量,可以写出真正自适应的组件:

.card-wrap {
  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;
  }
}

这个组合的意义在于:响应式规则写在组件自己的样式块里,而不是散落在全局断点中。组件被放进侧边栏时会自动变成单列,被放进主区域时会自动变成双列,无需外部干预。

用变量统一间距节奏

很多项目间距混乱,根源在于“随手写数值”。用一组等比变量约束之后,节奏自然统一:

:root {
  --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 变量:

$brand: var(--color-primary);
$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 里重新赋值变量。虽然变量本身不能平滑插值,但可以让关键帧在不同阶段用不同的变量值,配合其他可动画属性一起使用:

@keyframes pulse {
  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 {
  --icon-color: currentColor;
}

.icon path {
  fill: var(--icon-color);
}

.icon.danger { --icon-color: #ef4444; }

JS 读写变量

// 读:必须走 getComputedStyle,直接读 style 属性拿不到继承值
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 raw = getComputedStyle(el).getPropertyValue('--hue');
const hue = raw ? parseFloat(raw) : 221;

DevTools 里的变量调试

  1. Elements 面板:选中元素,右侧 Styles 面板会列出它继承到的所有自定义属性,未生效的变量名会显示为灰色。
  2. Computed 面板:可以看到每个属性的最终计算值,鼠标悬停时会显示 var() 的展开过程。
  3. 颜色选择器:点击带 var() 的颜色值,可以直接调色并实时写回变量——调试主题时非常高效。
  4. 强制状态:右键元素选择 :hover、:focus 等强制状态,可以直接检查状态变量的覆盖情况。

一个常见的性能误区

“CSS 变量比直接写值慢”是一个流传很广的说法。实际上,现代浏览器的样式引擎会把变量展开过程优化得很高效,在绝大多数页面上差异小到无法测量。真正影响性能的是:变量驱动的属性是否触发了不必要的重排,以及改变量的元素是否位于继承树的根节点上。

十一、十个最常见的 CSS 变量误区

误区 1 把 CSS 变量当成 Sass 变量的替代品,试图用它做循环和编译期数学
误区 2 在 @media 条件里使用 var(),结果整条规则失效
误区 3 变量没带单位,导致目标属性变成“无效的初始值”而非回退
误区 4 认为未注册的变量可以参与 transition 平滑过渡
误区 5 把所有变量都塞进 :root,组件之间无法隔离
误区 6 用外观命名变量:--red、--big-font,主题一换就全错
误区 7 在行内样式里写 var(--x, 10px) 却期望它覆盖外部定义
误区 8 忘记变量名大小写敏感,--Color 和 --color 是两个东西
误区 9 用 JS 直接读 el.style.getPropertyValue 去取继承值,结果永远是空字符串
误区 10 变量命名无层级,--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 颜色函数

渐进增强的写法

变量最大的好处是它天然可降级——先写一条静态声明,再写一条变量声明。老浏览器忽略第二条,新浏览器用第二条:

.card {
  background: #ffffff; /* 降级 */
  background: var(--card-bg, #ffffff); /* 增强 */
}

不过要提醒一句:不支持 CSS 变量的浏览器(如 IE11)早已不在现代前端项目的支持范围内,为它们写降级代码通常是浪费时间。上面这个模式真正的用途,是处理变量本身可能缺失的场景,而不是兼容老浏览器。

用 @supports 做特性检测

@supports (color: var(--x)) {
  /* 支持变量的浏览器 */
}

@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 把整站配色换掉,或者用三个变量重写整个组件的皮肤时,就会明白为什么规范委员会当初坚持把它做进浏览器,而不是留给构建工具。

所以,下次你在样式表里敲下一个十六进制颜色值之前,不妨先停半秒问自己一句:“这个值,以后会不会变?”——如果答案是“也许会”,那它就该是一个变量。