CSS自定义属性应用

张玥 2026年9月18日 阅读时间 35分钟
CSS变量 自定义属性 设计令牌 @property 主题换肤
CSS高级技巧视觉效果之CSS自定义属性应用

很多人把 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 是两个完全不同的东西,这既是灵活性的来源,也是坑的源头。
/* 一个变量可以承载任意 CSS 值 */
: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 内置属性名冲突,因为它本来就在不同的命名空间里。

点击下面的名字,看看哪些是合法的自定义属性名:

--brand-color --gap --2col -brand --my gap --Color-A --a$b --x

提示:名字里可以有数字,但不能以数字开头;可以有连字符,但不能只有一个。

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)。

/* 三层嵌套,观察 --c 的接力过程 */
.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 会跟着变 */
根元素:--c: #2563eb
在 :root 上声明,全站可见
子元素:--c: #10b981
就近覆盖,只影响自身与后代
孙元素:未声明 → 继承 #10b981
沿树向上找到最近的声明后停止

用作用域做“组件级私有变量”

这是自定义属性在工程上最有价值的用法之一:把变量定义在组件根节点上,而不是 :root。这样组件内部的变量不会污染全局,多个实例之间也互不干扰。

/* ❌ 全局污染式:所有按钮共享同一个 --btn-bg */
: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);
}
--brand 已定义
color: var(--brand, #2563eb) → 用 --brand
--brand 未定义
color: var(--brand, #2563eb) → 用 #2563eb
--brand 值为非法语法
不会走回退,整个声明变为无效
链式回退
逐层向上,直到找到有定义的那个

一个实战模式:可选参数

用“空回退”可以让组件支持可选修饰,而不需要写额外的选择器:

/* 组件默认没有边框,但允许外部通过 --card-border 注入 */
.card {
  border: var(--card-border, 0 solid transparent);
}

/* 使用时按需注入 */
.card--outlined {
  --card-border: 1px solid #cbd5e1;
}
.card--brand {
  --card-border: 2px solid #2563eb;
}

这种做法把“是否显示边框”这个决策从组件内部转移到了使用方,组件的 CSS 不需要为每一种情况写一条规则,体积更小、可组合性更强。

交互实验:用自定义属性实现主题换肤

主题换肤是自定义属性最经典的应用场景。核心思路是:把所有颜色收敛成一组语义化变量,主题切换时只改变量,不改任何具体属性。点击下面的按钮切换主题,观察同一套结构如何呈现出完全不同的视觉风格。

主题预览卡片
这张卡片的所有颜色都来自 CSS 自定义属性,切换主题时属性本身不变,只有变量的值被替换。
--td-bg --td-fg --td-accent

语义化命名:主题方案的地基

换肤方案能否长期维护,几乎完全取决于变量的命名方式。推荐使用三层命名法:

/* 第一层:原始调色板(Primitive) */
/* 只描述颜色本身,不带语义 */
: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);
}
--blue-600 → 原始色值 #2563eb
--accent → var(--blue-600)
--btn-bg → var(--accent)
background → var(--btn-bg)

三层解耦后,换主题只需改中间一层

主题切换的两种挂载方式

  • 类名 / 属性挂载(推荐):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 可以直接改写它,而无需重新拼接整段样式字符串。

/* ① 写入:setProperty */
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;
}
setProperty 写入,性能好,粒度细
getPropertyValue 读取计算后的值,注意 trim()
removeProperty 移除局部覆盖,回到继承值
适用场景 鼠标位置、进度、拖拽距离等动态量

读取时的两个陷阱

  • 值带空格: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 面板里直接改变量,立刻看到效果。
/* JS 只做一件事:写变量 */
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() 组合出任意结果。这让一套变量可以驱动整个页面的尺度体系。

/* 用一个 --scale 驱动整套间距 */
: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)));
}
--space-1 · calc(4px * var(--s))
--space-2 · calc(8px * var(--s))
--space-4 · calc(16px * var(--s))
--space-6 · calc(24px * var(--s))

一个必须记住的限制

calc() 里不能对两个带单位的变量做乘法,也不能把一个带单位的值当除数。所以把“纯数字”变量和“带单位”变量分开管理,是设计令牌体系的关键约定:

/* ❌ 错误:两个长度相乘,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;
}
--angle: <angle>
悬停我,颜色平滑过渡
--glow-a / --glow-b: <color>
--fill: <percentage>

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 的浏览器里,动画会失效,但页面不会崩——因为属性值依然可以被正常设置和使用,只是没有中间插值。因此正确的写法是:让最终状态是静态可读的,动画只作为增强。

/* 兜底:即使 --angle 不能动画,环形依然可见 */
.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);
}
色彩 品牌色、功能色、中性色阶
间距 基于 4px / 8px 基数的等比序列
字体 字族、字号、字重、行高
圆角阴影 层级感的统一语言
动效 时长与缓动的统一约定

从令牌到组件的映射

令牌本身只是原料,真正产生价值的是“映射”——把原始令牌组合成组件的语义接口:

/* 组件只依赖语义令牌,不依赖原始值 */
.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;
  }
}
媒体查询 跟随视口,适合页面级布局变量
容器查询 跟随组件容器,适合组件级适配
clamp() 流体缩放,无需断点
局限 变量不能作为媒体查询的条件本身

一个重要的限制

很多人想写 @media (min-width: var(--bp)),这是不行的。媒体查询的条件在解析阶段就需要确定,而自定义属性是运行时值。所以断点必须写成字面量,或者通过构建工具生成。

/* ❌ 无效:媒体查询条件不能使用 var() */
@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 层可接受,超过就要考虑扁平化 */
读取 几乎零成本,仅字符串查表
写入 成本取决于影响范围,越小越快
全页写入 改 :root 变量可能导致大面积重算
最佳实践 变量写在组件根,驱动的属性只走合成

三条实用的性能准则

  1. 用变量驱动 transform 和 opacity,而不是 width、left 这类布局属性。
  2. 变量作用域尽量小:能写在组件上就不要写在 :root 上。
  3. 避免在一帧内多次写变量:把多次 setProperty 合并到一次 requestAnimationFrame 里。

调试技巧:让变量在 DevTools 里现形

自定义属性最大的调试障碍是“值看不到”。好在现代 DevTools 已经提供了相当完善的支持。

/* ① Elements 面板:直接查看与修改 */
/* 选中元素 → 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 未定义或为空');
Elements 查看生效变量、实时改值
Computed 查看最终计算值
console.table 批量导出,排查继承链
outline 大法 可视化元素边界,定位错位

一个常见的调试误区

在 DevTools 里直接修改 :root 下的变量,看到的效果可能与实际运行时不同——因为实际运行时,可能有更靠近元素的选择器覆盖了它。所以调试变量一定要在“最终生效的元素”上看,而不是在 :root 上看。

常见陷阱与反模式

/* ❌ 陷阱 1:忘记 var() 会在字符串里失效 */
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:无节制地把所有值都变成变量 */
/* 只出现一次的值不需要抽变量,会降低可读性 */
反模式
把所有颜色、间距、字体、圆角、阴影、动画时长全部抽成变量,结果 CSS 变成“变量名查表游戏”,可读性反而下降。
正解
只抽会被复用、会被主题切换、会被 JS 动态修改的值。出现一次的字面量,直接写就好。

关于“变量太多会不会影响性能”

不会。浏览器解析自定义属性的开销极低,几百个变量的解析成本远小于一张大图的解码。真正需要担心的是变量的作用域过大以及用变量驱动了昂贵的属性,而不是变量本身的个数。

综合实战:一个由变量驱动的卡片组件

把前面所有技巧串起来,写一个完整、可直接复用的卡片组件。它支持主题、密度、圆角、阴影的全面参数化,并且所有参数都有安全回退。

/* ========== 1. 组件默认参数(全部带默认值) ========== */
.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 是不是某个尺度体系里的一环?如果是,给它一个名字。

希望这篇长文能帮你把自定义属性从“语法糖”升级为“架构工具”。文中所有实验台都可以亲手改一改参数,观察变量的作用域、继承与插值是如何一步步生效的——那比读十篇文章都管用。