很多人把 CSS 自定义属性(Custom Properties,俗称“CSS 变量”)当成一个“能省几行代码的语法糖”,这是对它最大的误解。它真正的身份是:CSS 世界里第一个可以参与级联、可以被继承、可以被 JavaScript 实时读写、还能被浏览器插值动画的运行时数据结构。正因为如此,它撑起了现代前端的整套设计令牌(Design Tokens)体系、主题换肤方案、组件参数化接口,以及 @property 带来的“自定义属性动画”这一全新能力。本篇 35 分钟长文,从语法细节讲到工程实践,从作用域与继承讲到类型注册与动画插值,中间穿插多个可以亲手操作的实验台,帮你彻底吃透这个看起来简单、用起来极深的特性。
自定义属性的本质:它比你想的更“活”
先看一个最朴素的例子:
:root {
--brand-color: #2563eb;
--gap: 16px;
}
/* 使用:通过 var() 函数引用 */
.card {
color: var(--brand-color);
padding: var(--gap);
}
如果只是这样,它确实没什么了不起。但请记住下面这几条,它们才是自定义属性真正的价值所在:
- 它参与级联:和普通 CSS 属性一样,遵循层叠、优先级、继承规则。你可以在任何选择器里重新定义它。
- 它天生可继承:默认情况下,子元素能读到父元素的值(除非用
@property把inherits设为false)。 - 它是运行时值:浏览器不会在解析阶段就把它替换掉,而是等到计算值时再解析。这意味着你可以用 JS 实时改写,页面立刻响应。
- 它可以被动画:一旦用
@property注册了类型,它就能像普通属性一样参与过渡与关键帧动画。 - 它没有单位约束:
--x: 16和--x: 16px是两个完全不同的东西,这既是灵活性的来源,也是坑的源头。
:root {
--a: 16px; /* 长度 */
--b: #2563eb; /* 颜色 */
--c: 1fr 2fr; /* 网格轨道 */
--d: cubic-bezier(0.2, 0, 0, 1); /* 缓动函数 */
--e: 0 4px 12px rgba(0,0,0,.1); /* 阴影 */
--f: "Segoe UI", sans-serif; /* 字体栈 */
--g: calc(100% - 32px); /* 表达式 */
}
/* 甚至可以是另一个变量(间接引用) */
:root {
--brand: #2563eb;
--button-bg: var(--brand);
--link-color: var(--brand);
}
--name: value; 两个短横线开头
var(--name) 带括号的函数
与 Sass / Less 变量的本质区别
这是面试里出现频率极高的问题。一句话总结:预处理器变量是“编译时的文本替换”,自定义属性是“运行时的 CSS 值”。
| 维度 | Sass / Less 变量 | CSS 自定义属性 |
|---|---|---|
| 生效时机 | 编译期,产物中已不存在 | 运行时,浏览器实时解析 |
| 能否被 JS 修改 | 不能 | 可以 |
| 是否参与级联 | 否 | 是 |
| 能否用于媒体查询条件 | 不能 | 不能(但可改变其值) |
| 能否被动画 | 不能 | 注册类型后可以 |
| 调试体验 | 需看编译产物 | DevTools 中可直接查看与修改 |
结论不是“二选一”,而是“两者配合”:用 Sass 做循环、函数、mixin 生成结构化的样式代码;用自定义属性做运行时可变的主题、尺寸与状态。
语法细节:名字、大小写与合法值
自定义属性的语法看似简单,但有几个细节一旦忽略就会写出“看起来对、跑起来错”的代码。
1. 命名规则
- 必须以两个短横线
--开头,例如--color、--my-gap。 - 大小写敏感:
--Color和--color是两个完全不同的变量。 - 不能包含空格、
$、[、]、^等字符,推荐使用 kebab-case。 --(只有两个短横线)本身是合法的自定义属性名,但极度不建议使用。- 不能与 CSS 内置属性名冲突,因为它本来就在不同的命名空间里。
点击下面的名字,看看哪些是合法的自定义属性名:
提示:名字里可以有数字,但不能以数字开头;可以有连字符,但不能只有一个。
2. 值的合法性:几乎什么都能存,但要注意空格
:root {
--shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
--font: "Noto Sans SC", system-ui, sans-serif;
--fn: cubic-bezier(0.4, 0, 0.2, 1);
}
/* ⚠️ 注意:值后面多余的空格会被保留 */
--a: 16px ; /* 尾部空格,拼接时可能出问题 */
/* ❌ 非法:单独的 !important 不能出现在值里 */
--a: red !important;
/* ✅ 正确做法:!important 写在属性上 */
color: var(--a) !important;
/* ⚠️ 空值是合法的,但会导致 var() 失效 */
--empty: ;
color: var(--empty); /* 等价于 color: ; 无效声明 */
3. 一个反直觉的行为:无效值的“后备继承”
这是自定义属性最反直觉、也最容易踩坑的一条规则:当 var() 引用了一个语法上非法的值,浏览器不会回退到继承值,而是把整个声明变为“无效的 var() 值”,并以该属性的初始值或继承值兜底。
.parent { --c: blue; color: red; }
.child {
--c: 16px; /* 一个长度,不是颜色 */
color: var(--c); /* 无效! */
}
/* 结果:child 的 color 不是 blue,也不是 red, */
/* 而是 color 的继承值(因为 color 可继承) */
/* 也就是父元素的 red */
/* 如果是不可继承属性,就会用初始值 */
.box {
--w: red;
width: var(--w); /* 无效 → width: auto(初始值) */
}
这个行为常被称为“invalid at computed-value time”(计算值阶段无效)。它带来两个实用推论:
- 想让非法值有兜底,必须显式写
var(--c, fallback),但注意fallback只在变量未定义时生效,变量已定义但值非法时不会走 fallback。 - 如果你希望“值非法时回到某个安全值”,正确做法是用
@property注册类型,让浏览器在替换前就做语法校验。
作用域与继承:自定义属性的“接力棒”
自定义属性的查找规则与普通继承属性完全一致:从当前元素开始,沿 DOM 树向上寻找最近的一个声明。找到就停,找不到就用初始值(通常是空值,除非用 @property 指定了 initial-value)。
.level-1 {
--c: #2563eb; /* 根:蓝色 */
}
.level-2 {
--c: #10b981; /* 子:覆盖为绿色 */
}
.level-3 {
/* 孙:没有声明,继承最近的绿色 */
border-color: var(--c);
}
/* 一个重要特性:值里的变量在“使用处”解析 */
.a { --size: 16px; }
.b { --gap: var(--size); } /* 只存表达式 */
.b { padding: var(--gap); }
/* 如果 .b 里重新定义了 --size,--gap 会跟着变 */
用作用域做“组件级私有变量”
这是自定义属性在工程上最有价值的用法之一:把变量定义在组件根节点上,而不是 :root。这样组件内部的变量不会污染全局,多个实例之间也互不干扰。
:root { --btn-bg: #2563eb; }
.btn { background: var(--btn-bg); }
/* ✅ 组件私有式:变量定义在组件作用域内 */
.btn {
--btn-bg: #2563eb;
--btn-fg: #ffffff;
--btn-radius: 8px;
background: var(--btn-bg);
color: var(--btn-fg);
border-radius: var(--btn-radius);
}
/* 变体:只覆盖变量,不重写属性 */
.btn--danger { --btn-bg: #ef4444; }
.btn--ghost {
--btn-bg: transparent;
--btn-fg: #2563eb;
}
.btn--round { --btn-radius: 999px; }
这种写法的好处是:组件的“属性接口”被显式地暴露出来。使用者只需覆盖变量,无需了解内部用了哪些 CSS 属性,也不需要跟选择器优先级斗智斗勇。
var() 与回退值:兜底的三种姿势
var() 接受两个参数:var(<自定义属性名>, <回退值>)。回退值可以省略,也可以嵌套另一个 var(),甚至包含逗号。
.a { color: var(--brand, #2563eb); }
/* ② 链式回退:一层层往上找 */
.b {
color: var(--brand, var(--primary, var(--fallback, #333)));
}
/* ③ 回退值里包含逗号:整体会被当作回退值 */
.c {
font-family: var(--font, "Helvetica Neue", Arial, sans-serif);
}
/* 注意:逗号后的所有内容都属于回退值 */
/* ④ 常见写法:让 --gap 有默认值,避免漏写时崩塌 */
.stack {
display: grid;
gap: var(--stack-gap, 16px);
}
/* ⑤ 空回退:让变量可选地“什么都不加” */
.shadow {
box-shadow: var(--elevation, ) 0 1px 2px rgba(0,0,0,.08);
}
color: var(--brand, #2563eb) → 用 --brand
color: var(--brand, #2563eb) → 用 #2563eb
不会走回退,整个声明变为无效
逐层向上,直到找到有定义的那个
一个实战模式:可选参数
用“空回退”可以让组件支持可选修饰,而不需要写额外的选择器:
.card {
border: var(--card-border, 0 solid transparent);
}
/* 使用时按需注入 */
.card--outlined {
--card-border: 1px solid #cbd5e1;
}
.card--brand {
--card-border: 2px solid #2563eb;
}
这种做法把“是否显示边框”这个决策从组件内部转移到了使用方,组件的 CSS 不需要为每一种情况写一条规则,体积更小、可组合性更强。
交互实验:用自定义属性实现主题换肤
主题换肤是自定义属性最经典的应用场景。核心思路是:把所有颜色收敛成一组语义化变量,主题切换时只改变量,不改任何具体属性。点击下面的按钮切换主题,观察同一套结构如何呈现出完全不同的视觉风格。
语义化命名:主题方案的地基
换肤方案能否长期维护,几乎完全取决于变量的命名方式。推荐使用三层命名法:
/* 只描述颜色本身,不带语义 */
:root {
--blue-500: #3b82f6;
--blue-600: #2563eb;
--gray-50: #f8fafc;
--gray-900: #0f172a;
}
/* 第二层:语义令牌(Semantic) */
/* 描述“用在哪里”,主题切换只改这一层 */
:root {
--surface: var(--gray-50);
--on-surface: var(--gray-900);
--accent: var(--blue-600);
}
/* 第三层:组件令牌(Component) */
/* 只服务于某个具体组件,可覆盖 */
.card {
--card-bg: var(--surface);
--card-fg: var(--on-surface);
}
三层解耦后,换主题只需改中间一层
主题切换的两种挂载方式
- 类名 / 属性挂载(推荐):
html[data-theme="dark"]。语义清晰,便于调试,也方便 JS 读写。 - 媒体查询自动跟随:
@media (prefers-color-scheme: dark)。适合“跟随系统”的默认行为。 - 两者结合:默认跟随系统,用户手动选择后写入
data-theme覆盖,是业界最成熟的做法。
:root {
--surface: #ffffff;
--on-surface: #0f172a;
}
@media (prefers-color-scheme: dark) {
:root {
--surface: #0f172a;
--on-surface: #e2e8f0;
}
}
/* 手动覆盖:优先级更高 */
html[data-theme="light"] {
--surface: #ffffff;
--on-surface: #0f172a;
}
html[data-theme="dark"] {
--surface: #0f172a;
--on-surface: #e2e8f0;
}
另外别忘了给颜色切换加上过渡,避免“啪”地一下闪变:
html {
transition: background-color 0.3s ease, color 0.3s ease;
}
/* 但要注意:不要给 * 加 transition,会拖慢整页 */
/* 更好的做法是只给颜色相关的容器加 */
body, .card, .btn, .nav {
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
}
与 JavaScript 协作:运行时改写的三种姿势
自定义属性与 JS 是天作之合。因为它是一个“运行时的 CSS 值”,JS 可以直接改写它,而无需重新拼接整段样式字符串。
const el = document.querySelector('.card');
el.style.setProperty('--gap', '24px');
el.style.setProperty('--brand', '#10b981');
/* ② 读取:getComputedStyle */
const cs = getComputedStyle(el);
const gap = cs.getPropertyValue('--gap').trim();
const brand = cs.getPropertyValue('--brand').trim();
/* ③ 移除:回到继承或初始值 */
el.style.removeProperty('--gap');
/* ④ 批量写入:先拼字符串再整体赋值 */
el.style.cssText += '--gap: 24px; --brand: #10b981;';
/* ⑤ 经典用法:把鼠标位置传给 CSS */
card.addEventListener('pointermove', (e) => {
const r = card.getBoundingClientRect();
card.style.setProperty('--mx', (e.clientX - r.left) + 'px');
card.style.setProperty('--my', (e.clientY - r.top) + 'px');
});
/* CSS 侧:用变量做径向高光 */
.card::after {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(circle 120px at var(--mx) var(--my), rgba(255,255,255,.35), transparent 70%);
opacity: 0;
transition: opacity 0.25s ease;
}
读取时的两个陷阱
- 值带空格:
getPropertyValue返回的值可能带前后空格,务必.trim()。 - 未定义时返回空字符串:不是
undefined也不是null,而是""。判断时要小心if (value)会把0px也当成假值。
性能提示:不要在滚动的每一帧都写变量
虽然 setProperty 本身很轻,但如果它导致大面积元素的样式重算,依然会掉帧。稳妥的做法是:
- 把写变量放进
requestAnimationFrame中,避免一帧内多次写入。 - 把变量写在尽量小的作用域上(比如只影响某个卡片),而不是
:root。 - 如果只是改
transform或opacity,直接写属性比绕道变量更快。
交互实验:用自定义属性打造组件参数面板
下面这个实验台把组件的尺寸、圆角、阴影、渐变色全部抽成自定义属性,拖动滑块时 JS 只负责 setProperty,其余全部交给 CSS。这正是设计系统里“组件 Playground”的标准实现方式。
为什么这种模式比直接改 style 更好?
- CSS 仍然掌握“怎么用”:JS 只提供数值,具体怎么渲染由样式表决定,职责清晰。
- 可以配合媒体查询:小屏幕上 CSS 可以忽略 JS 给的值,或者做 clamp 处理。
- 天然支持过渡:给变量所在的属性加
transition,改值时自动有动画。 - 可被 DevTools 实时调试:在 Elements 面板里直接改变量,立刻看到效果。
box.style.setProperty('--t-size', size + 'px');
box.style.setProperty('--t-radius', radius + 'px');
box.style.setProperty('--t-blur', blur + 'px');
box.style.setProperty('--t-c1', c1);
box.style.setProperty('--t-c2', c2);
/* CSS 决定怎么用,并且加好过渡 */
.box {
width: var(--t-size, 90px);
height: var(--t-size, 90px);
border-radius: var(--t-radius, 16px);
background: linear-gradient(135deg, var(--t-c1, #60a5fa), var(--t-c2, #2563eb));
box-shadow: 0 10px var(--t-blur, 24px) rgba(37,99,235,.4);
transition: width .25s ease, height .25s ease, border-radius .25s ease, box-shadow .25s ease;
}
注意这里的 var(--t-size, 90px) 里带了回退值。这不是可有可无的装饰,而是组件健壮性的保证——即使 JS 还没执行、或者被用户禁用,组件依然以默认形态正常渲染。
与 calc() 组合:让变量真正“可计算”
自定义属性最强大的地方,是它能把“数值”和“单位”分离,再通过 calc() 组合出任意结果。这让一套变量可以驱动整个页面的尺度体系。
:root {
--scale: 1;
--space-1: calc(4px * var(--scale));
--space-2: calc(8px * var(--scale));
--space-4: calc(16px * var(--scale));
--space-6: calc(24px * var(--scale));
}
/* 密度切换:一行代码改全局 */
.compact { --scale: 0.75; }
.comfortable { --scale: 1.25; }
/* 用百分比变量做进度条 */
.progress {
--p: 0.62;
width: calc(var(--p) * 100%);
}
/* 用行高变量做垂直居中偏移 */
.badge {
--h: 28px;
height: var(--h);
line-height: var(--h);
padding: 0 calc(var(--h) / 2);
border-radius: calc(var(--h) / 2);
}
/* 用三角函数做动态角度(现代浏览器) */
.dial {
--i: 3;
--total: 12;
--angle: calc(var(--i) / var(--total) * 360deg);
transform: rotate(var(--angle)) translateY(-60px) rotate(calc(-1 * var(--angle)));
}
一个必须记住的限制
calc() 里不能对两个带单位的变量做乘法,也不能把一个带单位的值当除数。所以把“纯数字”变量和“带单位”变量分开管理,是设计令牌体系的关键约定:
--a: 16px;
--b: 2px;
width: calc(var(--a) * var(--b)); /* 无效 */
/* ✅ 正确:数字 × 长度 */
--n: 2;
--a: 16px;
width: calc(var(--n) * var(--a)); /* 32px */
@property:给自定义属性上“类型户口”
默认情况下,自定义属性是“无类型”的——浏览器只知道它是一串 token,不知道它是颜色、长度还是角度。这带来两个后果:
- 不能动画:因为浏览器不知道两个值之间该怎么插值。
- 没有初始值:未定义时无法回退,只能靠
var()的回退参数。
@property 正是为解决这两个问题而生的。它允许你为自定义属性声明三件事:语法类型(syntax)、初始值(initial-value)、是否继承(inherits)。
@property --angle {
syntax: '<angle>';
initial-value: 0deg;
inherits: false;
}
/* 注册一个颜色类型 */
@property --glow {
syntax: '<color>';
initial-value: #2563eb;
inherits: false;
}
/* 注册一个百分比类型 */
@property --fill {
syntax: '<percentage>';
initial-value: 0%;
inherits: false;
}
/* 注册后,它们就能被动画了 */
@keyframes spin {
to { --angle: 360deg; }
}
.ring {
background: conic-gradient(from var(--angle), #2563eb, #10b981, #2563eb);
animation: spin 3s linear infinite;
}
syntax 支持的常见类型
| syntax 写法 | 含义 | 典型用途 |
|---|---|---|
'<length>' |
长度值 | 尺寸、间距、圆角动画 |
'<percentage>' |
百分比 | 进度条、透明度 |
'<number>' |
无单位数字 | 缩放系数、倍数 |
'<color>' |
颜色 | 渐变、主题色过渡 |
'<angle>' |
角度 | 锥形渐变旋转、指针角度 |
'<time>' |
时间 | 动画时长动态化 |
'<transform-function>' |
单个变换函数 | 动态位移、旋转 |
'*' |
任意值(不做类型校验) | 等价于未注册,仍不可动画 |
inherits: true 还是 false?
这是一个常被忽略但影响很大的决策:
inherits: true:子元素能读到父元素的值。适合语义化令牌,比如--accent、--text-color。inherits: false:只在声明元素上有效,子元素读到的是initial-value。适合组件私有变量,比如--spin-angle、--fill,避免意外泄漏到子元素。
实践中,动画用的属性几乎都设成 inherits: false,因为它们的值往往只对当前元素有意义。
没有 @property 时的退化方案
在不支持 @property 的浏览器里,动画会失效,但页面不会崩——因为属性值依然可以被正常设置和使用,只是没有中间插值。因此正确的写法是:让最终状态是静态可读的,动画只作为增强。
.ring {
--angle: 0deg; /* 显式声明,保证有值 */
background: conic-gradient(from var(--angle), #2563eb, #10b981, #2563eb);
}
@supports (background: conic-gradient(red, blue)) {
.ring { animation: spin 3s linear infinite; }
}
工程实践:用自定义属性搭建设计令牌体系
当一个项目从 3 个页面扩展到 30 个页面、从 1 个开发者扩展到 10 个开发者时,样式失控几乎是必然的。设计令牌(Design Tokens)正是对抗这种熵增的核心手段,而自定义属性是目前 Web 端最合适的载体。
令牌的五类分层
:root {
--color-blue-500: #3b82f6;
--color-gray-100: #f1f5f9;
--color-red-500: #ef4444;
}
/* ② 间距令牌(数字 × 基数) */
:root {
--space-unit: 4px;
--space-1: calc(var(--space-unit) * 1);
--space-2: calc(var(--space-unit) * 2);
--space-3: calc(var(--space-unit) * 3);
--space-4: calc(var(--space-unit) * 4);
--space-6: calc(var(--space-unit) * 6);
}
/* ③ 字体令牌 */
:root {
--font-sans: "Noto Sans SC", system-ui, sans-serif;
--font-mono: "JetBrains Mono", Consolas, monospace;
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--leading-tight: 1.25;
--leading-normal: 1.6;
}
/* ④ 圆角与阴影令牌 */
:root {
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 16px;
--radius-full: 999px;
--shadow-sm: 0 1px 2px rgba(15,23,42,.06);
--shadow-md: 0 4px 12px rgba(15,23,42,.10);
--shadow-lg: 0 12px 32px rgba(15,23,42,.14);
}
/* ⑤ 动效令牌 */
:root {
--dur-fast: 120ms;
--dur-base: 220ms;
--dur-slow: 360ms;
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
--ease-decelerate: cubic-bezier(0, 0, 0.2, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
}
从令牌到组件的映射
令牌本身只是原料,真正产生价值的是“映射”——把原始令牌组合成组件的语义接口:
.btn {
--btn-px: var(--space-4);
--btn-py: var(--space-2);
--btn-radius: var(--radius-md);
--btn-bg: var(--accent);
--btn-fg: var(--on-accent);
--btn-dur: var(--dur-fast);
--btn-ease: var(--ease-standard);
padding: var(--btn-py) var(--btn-px);
border-radius: var(--btn-radius);
background: var(--btn-bg);
color: var(--btn-fg);
transition: background-color var(--btn-dur) var(--btn-ease), transform var(--btn-dur) var(--btn-ease);
}
/* 换主题时,这个按钮不需要改动一行 */
令牌文件该放在哪里?
- 独立文件:
tokens.css或_variables.scss,在全局样式最前面引入。 - 分主题文件:
theme-light.css/theme-dark.css,只包含覆盖的变量。 - 不要散落在组件文件里:否则会出现“改一个颜色要翻十个文件”的窘境。
- 可以考虑自动生成:从 Figma Tokens、Style Dictionary 等工具导出,避免设计与代码脱节。
响应式与容器查询:让变量随环境变化
自定义属性可以写在任何选择器里,包括媒体查询和容器查询。这意味着同一套组件代码,可以在不同环境下呈现出不同的比例与密度。
:root {
--gutter: 16px;
--columns: 1;
}
@media (min-width: 768px) {
:root {
--gutter: 24px;
--columns: 2;
}
}
@media (min-width: 1200px) {
:root {
--gutter: 32px;
--columns: 3;
}
}
/* ② 用 clamp 做流体变量 */
:root {
--title-size: clamp(1.25rem, 4vw, 2.5rem);
--section-pad: clamp(16px, 5vw, 64px);
}
/* ③ 容器查询:组件根据自身宽度自适应 */
.card-wrapper {
container-type: inline-size;
}
.card {
--card-pad: 12px;
--card-title: 1rem;
padding: var(--card-pad);
font-size: var(--card-title);
}
@container (min-width: 480px) {
.card {
--card-pad: 24px;
--card-title: 1.25rem;
}
}
一个重要的限制
很多人想写 @media (min-width: var(--bp)),这是不行的。媒体查询的条件在解析阶段就需要确定,而自定义属性是运行时值。所以断点必须写成字面量,或者通过构建工具生成。
@media (min-width: var(--bp-md)) { }
/* ✅ 有效:容器查询可以用 var() */
@container (min-width: var(--bp-md)) { }
/* ✅ 也可用 JS 读取变量后动态设置断点 */
const bp = getComputedStyle(document.documentElement)
.getPropertyValue('--bp-md').trim();
性能影响:自定义属性到底贵不贵?
“CSS 变量有性能问题”是一个流传很广的说法。真相是:读取自定义属性几乎不花钱,写错作用域才会花钱。
/* ① 作用域过大:改 :root 会触发全页样式重算 */
:root { --x: 1px; }
document.documentElement.style.setProperty('--x', '2px');
/* 后果:所有使用 --x 的元素都要重新计算 */
/* ② 作用域精准:只影响一个组件 */
.card { --x: 1px; }
card.style.setProperty('--x', '2px');
/* 后果:只有这张卡片及其后代需要重算 */
/* ③ 让变量驱动的属性只走合成 */
.good {
--dx: 0px;
transform: translateX(var(--dx)); /* 只走 Composite */
}
.bad {
--w: 100px;
width: var(--w); /* 触发 Layout */
}
/* ④ 减少中间层:变量嵌套过深会增加解析成本 */
/* 一般 2~3 层可接受,超过就要考虑扁平化 */
三条实用的性能准则
- 用变量驱动
transform和opacity,而不是width、left这类布局属性。 - 变量作用域尽量小:能写在组件上就不要写在
:root上。 - 避免在一帧内多次写变量:把多次
setProperty合并到一次requestAnimationFrame里。
调试技巧:让变量在 DevTools 里现形
自定义属性最大的调试障碍是“值看不到”。好在现代 DevTools 已经提供了相当完善的支持。
/* 选中元素 → Styles → 底部会列出所有生效变量 */
/* 悬停变量名可看到最终解析值 */
/* ② Computed 面板:查看最终计算值 */
/* 勾选 Show all 可以看到未使用到的变量 */
/* ③ 快速调试:临时给元素加边框 */
* { outline: 1px solid rgba(255, 0, 0, .2); }
/* ④ 用 JS 批量导出所有变量 */
function dumpVars(el) {
const cs = getComputedStyle(el);
const out = {};
for (const name of cs) {
if (name.startsWith('--')) {
out[name] = cs.getPropertyValue(name).trim();
}
}
return out;
}
console.table(dumpVars(document.documentElement));
/* ⑤ 检查某个变量是否被解析为“空” */
const v = getComputedStyle(el).getPropertyValue('--x').trim();
if (!v) console.warn('--x 未定义或为空');
一个常见的调试误区
在 DevTools 里直接修改 :root 下的变量,看到的效果可能与实际运行时不同——因为实际运行时,可能有更靠近元素的选择器覆盖了它。所以调试变量一定要在“最终生效的元素”上看,而不是在 :root 上看。
常见陷阱与反模式
content: "var(--label)"; /* 输出字面量 */
content: var(--label); /* 正确 */
/* ❌ 陷阱 2:把变量当函数参数时忘了逗号 */
color: rgb(var(--r) var(--g) var(--b)); /* 无效 */
color: rgb(var(--r), var(--g), var(--b)); /* 正确 */
/* ❌ 陷阱 3:在变量里存了分号 */
--a: 16px; /* 分号会被当成语法错误 */
/* ❌ 陷阱 4:大小写不一致 */
:root { --BrandColor: #2563eb; }
.btn { color: var(--brandcolor); } /* 找不到!*/
/* ❌ 陷阱 5:用变量做属性名 */
--prop: color;
var(--prop): red; /* 不存在这种写法 */
/* ❌ 陷阱 6:在 @keyframes 里改变量控制另一动画 */
/* 变量是逐帧替换的,会导致大量重算,慎用 */
/* ❌ 陷阱 7:无节制地把所有值都变成变量 */
/* 只出现一次的值不需要抽变量,会降低可读性 */
关于“变量太多会不会影响性能”
不会。浏览器解析自定义属性的开销极低,几百个变量的解析成本远小于一张大图的解码。真正需要担心的是变量的作用域过大以及用变量驱动了昂贵的属性,而不是变量本身的个数。
综合实战:一个由变量驱动的卡片组件
把前面所有技巧串起来,写一个完整、可直接复用的卡片组件。它支持主题、密度、圆角、阴影的全面参数化,并且所有参数都有安全回退。
.ds-card {
--card-bg: var(--surface, #ffffff);
--card-fg: var(--on-surface, #0f172a);
--card-sub: var(--muted, #64748b);
--card-border: 1px solid var(--outline, #e2e8f0);
--card-radius: var(--radius-md, 8px);
--card-pad: calc(var(--space-unit, 4px) * 5);
--card-shadow: var(--shadow-sm, 0 1px 2px rgba(15,23,42,.06));
--card-lift: -4px;
--card-dur: var(--dur-base, 220ms);
--card-ease: var(--ease-standard, cubic-bezier(.2,0,0,1));
}
/* ========== 2. 组件本体(只消费变量) ========== */
.ds-card {
background: var(--card-bg);
color: var(--card-fg);
border: var(--card-border);
border-radius: var(--card-radius);
padding: var(--card-pad);
box-shadow: var(--card-shadow);
contain: layout paint;
transition: transform var(--card-dur) var(--card-ease), box-shadow var(--card-dur) var(--card-ease), background-color var(--card-dur) var(--card-ease);
}
/* ========== 3. 状态:只改变量 ========== */
.ds-card:hover {
--card-shadow: var(--shadow-lg, 0 12px 32px rgba(15,23,42,.14));
transform: translateY(var(--card-lift));
}
/* ========== 4. 变体:覆盖变量,不重写属性 ========== */
.ds-card--flat {
--card-shadow: none;
--card-lift: 0;
}
.ds-card--accent {
--card-border: 2px solid var(--accent, #2563eb);
--card-radius: var(--radius-lg, 16px);
}
.ds-card--compact {
--card-pad: calc(var(--space-unit, 4px) * 3);
}
/* ========== 5. 无障碍:减少动态效果 ========== */
@media (prefers-reduced-motion: reduce) {
.ds-card { --card-lift: 0; }
}
这个组件的设计要点
- 每一个参数都有回退:即使脱离设计系统单独使用,也能正常渲染。
- 状态与变体只改变量:没有任何一条规则重写了
padding或background。 - 变量名带前缀:
--card-*一眼就能看出属于这个组件,避免与全局令牌混淆。 - 用
contain隔离:内部变化不会影响外部布局,降低重绘范围。 - 尊重无障碍偏好:减少动效时,位移自动归零。
--card-radius 之类的变量,无需了解内部结构,也不会与选择器优先级产生冲突。
.my-card .ds-card { border-radius: 16px !important; },脆弱且难以维护。
最佳实践清单
把全文的结论浓缩成一份可以贴在工位上的清单。
- 命名使用 kebab-case 并带语义前缀:
--color-brand优于--c1。 - 区分“原始色值”与“语义令牌”:不要让组件直接引用
--blue-500。 - 变量定义在最小必要作用域:组件私有变量写在组件根节点上。
- 永远提供回退值:
var(--x, 16px),让组件脱离上下文也能工作。 - 用
calc()组合数值与单位:把比例系数与基础长度分开管理。 - 需要动画的属性用
@property注册类型:颜色、角度、百分比都能平滑插值。 inherits: false用于动画变量:避免意外泄漏到子元素。- 主题切换用
data-theme属性挂载,并提供跟随系统的默认值。 - JS 只写变量,不写具体属性:把“怎么用”留给 CSS。
- 写入变量时控制作用域:能写在组件上就不要写在
:root上。 - 让变量驱动的属性尽量走合成:优先
transform与opacity。 - 不要在媒体查询条件里用 var():断点必须写字面量。
- 注意大小写敏感:
--Color与--color不是同一个变量。 - 用 DevTools 的 Computed 面板排查:在最终生效的元素上查看解析值。
- 别过度抽象:只出现一次的值不需要抽成变量。
/* 1. 原始调色板 */
:root {
--blue-500: #3b82f6;
--blue-600: #2563eb;
--green-500: #10b981;
--red-500: #ef4444;
--gray-50: #f8fafc;
--gray-900: #0f172a;
}
/* 2. 语义令牌(可被主题覆盖) */
:root {
--surface: #ffffff;
--on-surface: #0f172a;
--muted: #64748b;
--outline: #e2e8f0;
--accent: var(--blue-600);
--on-accent: #ffffff;
}
html[data-theme="dark"] {
--surface: #0f172a;
--on-surface: #e2e8f0;
--muted: #94a3b8;
--outline: #1e293b;
--accent: #60a5fa;
}
/* 3. 尺度令牌 */
:root {
--space-unit: 4px;
--radius-md: 8px;
--radius-lg: 16px;
--shadow-sm: 0 1px 2px rgba(15,23,42,.06);
--shadow-lg: 0 12px 32px rgba(15,23,42,.14);
}
/* 4. 动效令牌 */
:root {
--dur-fast: 120ms;
--dur-base: 220ms;
--dur-slow: 360ms;
--ease-standard: cubic-bezier(.2, 0, 0, 1);
--ease-spring: cubic-bezier(.34, 1.56, .64, 1);
}
/* 5. 需要动画的变量显式注册 */
@property --spin {
syntax: '<angle>';
initial-value: 0deg;
inherits: false;
}
/* 6. 无障碍降级 */
@media (prefers-reduced-motion: reduce) {
:root {
--dur-fast: 0.01ms;
--dur-base: 0.01ms;
--dur-slow: 0.01ms;
}
}
自定义属性之所以重要,不是因为它能“少写几行代码”,而是因为它第一次让 CSS 拥有了可组合、可覆盖、可运行时修改、可被类型系统约束的抽象能力。它把散落在各处的魔法数字收敛成一套可命名、可讨论、可治理的语言。当你下一次想写 margin: 13px 的时候,不妨先问自己一句:这个 13 是不是某个尺度体系里的一环?如果是,给它一个名字。
希望这篇长文能帮你把自定义属性从“语法糖”升级为“架构工具”。文中所有实验台都可以亲手改一改参数,观察变量的作用域、继承与插值是如何一步步生效的——那比读十篇文章都管用。