CSS 自定义属性(变量)

简单 🟢CSS 布局
7 个标签
预计阅读时间:49 分钟
CSS变量自定义属性主题calc换肤design tokens

CSS 自定义属性(变量)

CSS 自定义属性(CSS Variables,规范名 CSS Custom Properties)允许定义可复用的值,提高代码的可维护性和灵活性。它和 Sass 变量最大的不同在于:它是"活的"——运行时可读可改,还会随 DOM 级联和继承。这一点让它成为主题换肤、动态样式、设计令牌(design tokens)落地的首选工具。

一个直观的类比

Sass 变量像"写文章时的查找替换":编译时把 `$primary` 全部替换成 `#007bff`,产物里根本没有变量这回事。CSS 变量像"文档里的引用/字段":产物里始终存在这个字段,页面运行时改一下字段值,所有引用它的地方立刻更新。正因为它活到了运行时,才能配合 JavaScript、媒体查询、`:hover` 等动态改变。

为什么重要

单一数据源:一处改色,全站生效,避免"改了 8 个地方还漏 2 个"。
主题/换肤几乎零成本:切换一个属性或 `data-theme`,整套配色瞬间切换,无需重载 CSS。
可被 JS 读写:能做交互式主题、跟随滚动/鼠标的动态样式。
天然级联与继承:局部覆盖非常自然,父级改一个变量,子树全部跟着变。
设计系统落地:把 design tokens(颜色、间距、圆角、阴影)编码成变量,设计与开发共享同一套值。

基本语法

定义变量:

`--variable-name: value;`
必须在某个规则集(选择器)内定义,最常见是 `:root`(等价于 html,作全局)
变量名区分大小写,习惯用 kebab-case

使用变量:

`var(--variable-name)`
`var(--variable-name, fallback)`:变量无效/未定义时用回退值
回退值本身也可以是另一个 `var()`,可层层兜底

示例:

cssCode
:root {
  --primary-color: #007bff;
  --font-size: 16px;
}

.button {
  background-color: var(--primary-color);
  font-size: var(--font-size, 14px); /* 若未定义则 14px */
}

作用域和继承

全局变量:

定义在 `:root` 中,整个文档可用,如 `--primary-color`

局部变量:

定义在特定选择器中,仅在该选择器及其后代中可用
可以覆盖同名的全局变量(就近生效)

继承:

变量遵循 CSS 继承规则,子元素继承父元素上生效的变量值
可以在任意层级重新定义来覆盖
这正是"父容器切换 `data-theme`,整棵子树换肤"能成立的原因

代码示例

定义和使用变量(设计令牌雏形)

cssCode
:root {
  /* 颜色 */
  --primary-color: #007bff;
  --secondary-color: #6c757d;
  --success-color: #28a745;
  --danger-color: #dc3545;
  --warning-color: #ffc107;

  /* 字体 */
  --font-family-base: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  --font-size-base: 16px;
  --font-size-large: 1.25rem;
  --font-size-small: 0.875rem;

  /* 间距(用一个基准单位派生一整套) */
  --spacing-unit: 8px;
  --spacing-sm: var(--spacing-unit);
  --spacing-md: calc(var(--spacing-unit) * 2);
  --spacing-lg: calc(var(--spacing-unit) * 3);

  /* 边框与阴影 */
  --border-radius: 4px;
  --border-width: 1px;
  --border-color: #dee2e6;
  --box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  --box-shadow-lg: 0 4px 8px rgba(0, 0, 0, 0.15);
}

.button {
  padding: var(--spacing-md) var(--spacing-lg);
  background-color: var(--primary-color);
  color: white;
  border: none;
  border-radius: var(--border-radius);
  font-family: var(--font-family-base);
  font-size: var(--font-size-base);
  box-shadow: var(--box-shadow);
  cursor: pointer;
  transition: all 0.3s ease;
}

.button:hover { box-shadow: var(--box-shadow-lg); }

.button--secondary { background-color: var(--secondary-color); }
.button--success   { background-color: var(--success-color); }
.button--danger    { background-color: var(--danger-color); }

主题切换(换肤)

cssCode
/* 浅色主题(默认) */
:root {
  --bg-primary: #ffffff;
  --bg-secondary: #f8f9fa;
  --text-primary: #212529;
  --text-secondary: #6c757d;
  --border-color: #dee2e6;
  --shadow-color: rgba(0, 0, 0, 0.1);
}

/* 深色主题:只改变量,其它样式不动 */
[data-theme="dark"] {
  --bg-primary: #1a1a1a;
  --bg-secondary: #2d2d2d;
  --text-primary: #ffffff;
  --text-secondary: #b0b0b0;
  --border-color: #404040;
  --shadow-color: rgba(0, 0, 0, 0.3);
}

body {
  background-color: var(--bg-primary);
  color: var(--text-primary);
  transition: background-color 0.3s, color 0.3s;
}

.card {
  background-color: var(--bg-secondary);
  border: 1px solid var(--border-color);
  box-shadow: 0 2px 4px var(--shadow-color);
  color: var(--text-primary);
}

.text-secondary { color: var(--text-secondary); }
javascriptCode
// JavaScript 切换主题
function toggleTheme() {
  const current = document.documentElement.getAttribute('data-theme');
  const next = current === 'dark' ? 'light' : 'dark';
  document.documentElement.setAttribute('data-theme', next);
  localStorage.setItem('theme', next);
}

// 初始化:优先读本地存储,其次跟随系统偏好
const saved = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.setAttribute('data-theme', saved || (prefersDark ? 'dark' : 'light'));

用 JavaScript 读写变量

javascriptCode
const root = document.documentElement;

// 读取变量值
const primary = getComputedStyle(root).getPropertyValue('--primary-color').trim();

// 设置变量值(立即全站生效)
root.style.setProperty('--primary-color', '#e91e63');

// 交互式:跟随鼠标位置更新变量,做聚光灯/渐变效果
document.addEventListener('mousemove', (e) => {
  root.style.setProperty('--mouse-x', e.clientX + 'px');
  root.style.setProperty('--mouse-y', e.clientY + 'px');
});

与 calc() 结合使用

cssCode
:root {
  --spacing-unit: 8px;
  --border-width: 2px;
  --font-size: 16px;
}

.card {
  padding: calc(var(--spacing-unit) * 2);
  margin: calc(var(--spacing-unit) * 1.5);
  border: var(--border-width) solid #ddd;
  font-size: calc(var(--font-size) * 1.125);
}

.grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: calc(var(--spacing-unit) * 2);
}

@media (max-width: 768px) {
  .grid {
    grid-template-columns: repeat(2, 1fr);
    gap: calc(var(--spacing-unit) * 1.5);
  }
}

局部覆盖:组件级变量

cssCode
/* 组件内部用变量做"可配置接口",外部改一个变量就能定制 */
.badge {
  --badge-bg: var(--primary-color);
  --badge-fg: #fff;
  background: var(--badge-bg);
  color: var(--badge-fg);
  padding: 2px 8px;
  border-radius: 10px;
}

/* 危险徽标只需覆盖局部变量,无需重写整块样式 */
.badge--danger { --badge-bg: var(--danger-color); }
.badge--muted  { --badge-bg: #e9ecef; --badge-fg: #495057; }

媒体查询里改变量:一处定义,全局响应

cssCode
:root { --gap: 24px; --cols: 4; }

@media (max-width: 1024px) { :root { --gap: 16px; --cols: 3; } }
@media (max-width: 768px)  { :root { --gap: 12px; --cols: 2; } }
@media (max-width: 480px)  { :root { --gap: 8px;  --cols: 1; } }

.grid {
  display: grid;
  grid-template-columns: repeat(var(--cols), 1fr);
  gap: var(--gap);
}
/* 所有用到 --gap / --cols 的地方都自动跟着断点变化 */

@property 注册变量(可做动画/类型约束)

cssCode
/* 普通 CSS 变量不能被平滑动画;注册后带类型可插值 */
@property --angle {
  syntax: '<angle>';
  inherits: false;
  initial-value: 0deg;
}

.spinner {
  background: conic-gradient(#007bff var(--angle), #eee 0);
  animation: rotate 1s linear infinite;
}

@keyframes rotate {
  to { --angle: 360deg; } /* 因已注册类型,能平滑过渡 */
}

与预处理器变量的区别

CSS 变量(`--x`):

运行时生效,可用 JS 动态修改
遵循级联和继承,能被局部覆盖
存在于产物中,需要浏览器支持

Sass/Less 变量(`$x` / `@x`):

编译时确定,产物里被替换为字面值
不能运行时修改,无继承概念
适合做编译期计算(循环生成、函数运算)

两者常常配合:用 Sass 做编译期批量生成,用 CSS 变量承载运行时可变的主题值。

真实案例

案例 1:一键换肤。 某 SaaS 后台需要浅色/深色/护眼三套主题。早期为每套主题打包一份 CSS(约 3 份 × 各 200KB),切换要重新加载样式、有白屏闪烁。改为一套样式 + 三组 CSS 变量后,切换只改根节点 `data-theme`,零网络请求、无闪烁,CSS 总体积下降约 60%。

案例 2:品牌可配置。 某白标(white-label)产品要给不同客户换主色。以前每个客户维护一份编译好的 Sass 变量产物。改为运行时注入 CSS 变量后,服务端只下发一段 `:root { --brand: #xxx }`,同一份 CSS 服务所有客户,新客户接入从 1 天缩短到几分钟。

案例 3:交互式渐变。 某落地页要做"跟随鼠标的聚光灯"效果。用 JS 每帧更新 `--mouse-x/--mouse-y` 两个变量,CSS 用 `radial-gradient` 引用,避免了每帧重写整段内联样式,代码更干净、性能更好。

数据与对比

| 维度 | CSS 变量 | Sass 变量 |

| --- | --- | --- |

| 生效时机 | 运行时 | 编译时 |

| JS 可读写 | 是 | 否 |

| 级联/继承 | 有 | 无 |

| 局部覆盖 | 天然支持 | 需重新赋值/作用域技巧 |

| 编译期计算 | 弱(靠 calc) | 强(循环/函数) |

| 产物体积 | 变量保留在产物 | 被替换为字面值 |

| 典型用途 | 主题、动态样式、tokens | 批量生成、编译期逻辑 |

浏览器支持:所有现代浏览器(Chrome 49+、Firefox 31+、Safari 9.1+、Edge 15+)全面支持 CSS 变量,全球覆盖率约 97%+;IE 全系不支持。`@property` 支持稍晚(Chrome 85+、Safari 16.4+、Firefox 128+)。

常见坑

1.在 `:root` 之外定义却指望全局用:变量只在定义它的选择器子树内可见,写错作用域会取到空值。
2.`var()` 无效时的"意外继承":若变量未定义又没给 fallback,属性会变成 invalid,进而取继承值或初始值,容易出现"莫名其妙的颜色"。养成写 fallback 的习惯。
3.变量名拼写/大小写错:`--Primary` 和 `--primary` 是两个变量,且拼错不会报错,只是静默失效。
4.想让普通变量平滑动画:未注册的自定义属性无法被 transition/animation 插值,需要用 `@property` 注册类型。
5.变量里塞不完整的值再拼:`--pad: 8` 然后 `padding: var(--pad)px` 是无效的,应存完整值 `--pad: 8px` 或用 `calc(var(--pad) * 1px)`。
6.深色模式忘了给 `color-scheme`:设了深色变量但没设 `color-scheme: dark`,表单控件、滚动条可能还是浅色。
7.JS 读值带空格:`getPropertyValue` 返回值前面可能有空格,记得 `.trim()`。

最佳实践

用语义化命名(`--color-primary`、`--space-md`),而非表象命名(`--blue`)
建立命名规范与分层:全局 tokens 放 `:root`,组件级变量放组件选择器
用一个基准单位 + `calc()` 派生间距/字号阶梯,保证系统一致
关键 `var()` 提供 fallback,提升健壮性
主题切换用 `data-theme` + 变量覆盖,配合 `color-scheme`
需要动画的变量用 `@property` 注册类型
与 Sass 配合:编译期逻辑交给 Sass,运行时可变值交给 CSS 变量
为团队文档化变量清单,作为设计系统的 tokens 来源

深入:无效值与"计算时无效"(IACVT)

CSS 变量有一个反直觉但极其重要的机制:当 `var()` 引用的变量值在使用处不合法时,该属性不会回退到你以为的默认值,而是变成 `unset`——即取继承值(可继承属性)或初始值(不可继承属性)。这叫 IACVT(Invalid At Computed Value Time,计算时无效)。

cssCode
:root {
  --color: 20px;   /* 存了一个长度值 */
}
.box {
  color: red;                 /* 先设一个正常颜色 */
  color: var(--color);        /* 20px 不是合法颜色 → 该声明作废 */
  /* 结果不是 red,而是继承来的颜色或初始值 currentColor 逻辑,
     很多人误以为会回退到上一行的 red,其实不会 */
}

关键结论: `var()` 的第二个参数(fallback)只在变量"未定义/为空"时生效,不能兜住"值非法"的情况。因此存值时就要保证类型正确,或用 `@property` 约束类型(见下)。

cssCode
/* fallback 只兜"没定义",不兜"定义了但非法" */
.a { color: var(--maybe-undefined, blue); } /* --maybe-undefined 没定义 → blue,OK */

:root { --bad: 10px; }
.b { color: var(--bad, blue); }             /* 定义了但非法 → 不是 blue,而是 IACVT */

@property 深入:类型、动画与继承控制

`@property` 把自定义属性"升级"为带类型定义的注册属性,带来三个能力:类型校验(非法值被丢弃而非污染)、可动画(能被插值过渡)、可控制继承。

cssCode
@property --brand-h {
  syntax: '<number>';   /* 只接受数字 */
  inherits: false;      /* 不向子元素继承 */
  initial-value: 210;   /* 默认色相 */
}
@property --progress {
  syntax: '<percentage>';
  inherits: false;
  initial-value: 0%;
}

/* 注册后,非法值会被忽略并回退到 initial-value,而不是引发 IACVT */
.ring {
  background: conic-gradient(#4caf50 var(--progress), #eee 0);
  transition: --progress 0.6s ease;  /* 现在能平滑动画了 */
}
.ring:hover { --progress: 75%; }

常见 syntax 取值:``、``、``、``、``、``、``、``、``,以及用 `|` 组合的联合类型和 `+`/`#` 表示的列表。

cssCode
/* 联合类型 + 列表示例 */
@property --gap {
  syntax: '<length> | auto';
  inherits: true;
  initial-value: 16px;
}

也可以用 JS 注册(需在样式解析前执行):

javascriptCode
CSS.registerProperty({
  name: '--tilt',
  syntax: '<angle>',
  inherits: false,
  initialValue: '0deg',
});

分层设计令牌:primitive → semantic → component

成熟设计系统会把变量分三层,避免"到处直接引用原始色值"造成的强耦合。改主题时只动语义层,原始层与组件层几乎不用改。

cssCode
:root {
  /* 第 1 层 primitive(原始值,不带语义,只是调色板) */
  --blue-500: #3b82f6;
  --blue-600: #2563eb;
  --gray-50:  #f9fafb;
  --gray-900: #111827;
  --red-500:  #ef4444;

  /* 第 2 层 semantic(语义令牌,业务只认这层) */
  --color-bg:        var(--gray-50);
  --color-text:      var(--gray-900);
  --color-accent:    var(--blue-500);
  --color-accent-hover: var(--blue-600);
  --color-danger:    var(--red-500);

  /* 第 3 层 component(组件令牌,可被单组件覆盖) */
  --button-bg:       var(--color-accent);
  --button-bg-hover: var(--color-accent-hover);
  --button-fg:       #fff;
}

/* 深色主题只需重定义语义层,原始层与组件层自动跟随 */
[data-theme='dark'] {
  --color-bg:     var(--gray-900);
  --color-text:   var(--gray-50);
  --color-accent: var(--blue-600);
}

.button {
  background: var(--button-bg);
  color: var(--button-fg);
}
.button:hover { background: var(--button-bg-hover); }

这套分层的好处用数据说话:某设计系统接入分层令牌后,新增一套品牌主题平均改动从约 120 处色值引用降到只改 18 个语义令牌,主题工时下降约 85%。

变量开关技巧:用变量做"CSS 布尔逻辑"

一个进阶技巧(Lea Verou 提出的 space toggle):利用"空值让整段声明作废、非空值让声明生效"的特性,用变量当开关,不写一个媒体查询或 JS 就能切换成组样式。

cssCode
/* --on 为空(初始)时代表"关",设为 initial 之外的值代表"开" */
.card {
  --is-featured: ;                 /* 空格 = 关闭状态 */
  --featured-shadow: var(--is-featured) 0 8px 30px rgba(0,0,0,.15);
  box-shadow: var(--featured-shadow, none);
  border: var(--is-featured, 1px solid #eee);
}

/* 开启:给变量一个非空值,上面所有引用它的声明同时生效 */
.card.is-featured {
  --is-featured: initial;
}

同理可实现"多值切换器":

cssCode
.alert {
  /* 默认(info) */
  --bg: #e7f3ff;
  --fg: #0b5cad;
}
.alert[data-type='success'] { --bg: #e6f7ec; --fg: #1a7f37; }
.alert[data-type='warning'] { --bg: #fff8e6; --fg: #9a6700; }
.alert[data-type='danger']  { --bg: #ffebe9; --fg: #cf222e; }

.alert {
  background: var(--bg);
  color: var(--fg);
  border-left: 4px solid var(--fg);
  padding: 12px 16px;
}

变量 + calc / min / max / clamp 组合

变量真正的威力在于参与运算。把"基准 + 系数"拆开存,能派生出整套一致的比例系统。

cssCode
:root {
  --step: 0.25rem;          /* 间距基准单位 4px */
  --ratio: 1.25;            /* 模块化字号比例(大三度) */
  --base-fs: 1rem;

  /* 间距阶梯:全部由 --step 派生 */
  --sp-1: calc(var(--step) * 1);
  --sp-2: calc(var(--step) * 2);
  --sp-3: calc(var(--step) * 3);
  --sp-4: calc(var(--step) * 4);
  --sp-6: calc(var(--step) * 6);
  --sp-8: calc(var(--step) * 8);

  /* 字号阶梯:按 ratio 逐级放大 */
  --fs-0: var(--base-fs);
  --fs-1: calc(var(--fs-0) * var(--ratio));
  --fs-2: calc(var(--fs-1) * var(--ratio));
  --fs-3: calc(var(--fs-2) * var(--ratio));

  /* 流式容器宽度:不小于 320,不大于 1200,中间跟随视口 */
  --container: clamp(320px, 90vw, 1200px);
}

.title    { font-size: var(--fs-3); margin-bottom: var(--sp-4); }
.subtitle { font-size: var(--fs-1); margin-bottom: var(--sp-2); }
.wrap     { width: var(--container); margin-inline: auto; }
cssCode
/* 用变量做"响应式而不写媒体查询"的列数 */
.grid {
  --min-col: 240px;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(var(--min-col), 100%), 1fr));
  gap: var(--sp-4);
}
/* min(--min-col, 100%) 防止单列时超出容器溢出 */

变量与动画:错峰动画与交互反馈

把索引、坐标存进变量,能用一套 CSS 实现"错峰入场"等原本要 JS 计算的效果。

cssCode
/* 列表项依次淡入:每项延迟 = 索引 × 单位延迟 */
.list-item {
  opacity: 0;
  animation: fade-in 0.4s ease forwards;
  animation-delay: calc(var(--i) * 80ms);  /* --i 由行内 style 提供 */
}
@keyframes fade-in {
  to { opacity: 1; transform: translateY(0); }
}
htmlCode
<ul>
  <li class="list-item" style="--i: 0">第一项</li>
  <li class="list-item" style="--i: 1">第二项</li>
  <li class="list-item" style="--i: 2">第三项</li>
  <li class="list-item" style="--i: 3">第四项</li>
</ul>
cssCode
/* 跟随鼠标的聚光灯卡片:JS 只更新两个变量,CSS 负责渲染 */
.spotlight {
  position: relative;
  background:
    radial-gradient(
      200px circle at var(--mx, 50%) var(--my, 50%),
      rgba(59,130,246,.25),
      transparent 60%
    ),
    #1e293b;
}
javascriptCode
const card = document.querySelector('.spotlight');
card.addEventListener('pointermove', (e) => {
  const rect = card.getBoundingClientRect();
  card.style.setProperty('--mx', (e.clientX - rect.left) + 'px');
  card.style.setProperty('--my', (e.clientY - rect.top) + 'px');
});

变量与 JavaScript 深度交互

除了 setProperty/getPropertyValue,还有几种进阶用法。

javascriptCode
// 1) 批量读取一组令牌,导出给 Canvas/图表库复用同一套配色
function readTokens(names) {
  const s = getComputedStyle(document.documentElement);
  return Object.fromEntries(
    names.map((n) => [n, s.getPropertyValue('--' + n).trim()])
  );
}
const theme = readTokens(['color-accent', 'color-bg', 'color-text']);

// 2) 用 rAF 节流高频更新(滚动/鼠标),避免掉帧
let ticking = false;
window.addEventListener('scroll', () => {
  if (ticking) return;
  ticking = true;
  requestAnimationFrame(() => {
    const p = window.scrollY / (document.body.scrollHeight - innerHeight);
    document.documentElement.style.setProperty('--scroll', p.toFixed(4));
    ticking = false;
  });
});

// 3) 从局部元素读取"就近生效"的变量值(体现级联)
const el = document.querySelector('.badge--danger');
const badgeBg = getComputedStyle(el).getPropertyValue('--badge-bg').trim();

配合滚动进度变量,可以做无 JS 计算的进度条:

cssCode
.progress-bar {
  transform-origin: left;
  transform: scaleX(var(--scroll, 0));
  height: 3px;
  background: var(--color-accent);
}

作用域、继承与 inherits 控制

CSS 变量默认可继承,但注册属性可以关掉继承,这在做"仅本节点生效"的令牌时很有用。

cssCode
/* 默认继承:父级定义,整棵子树可用 */
.theme-scope { --local-accent: #e91e63; }
.theme-scope .link { color: var(--local-accent); } /* 拿得到 */

/* 关闭继承:只在定义它的元素上生效,子元素取 initial-value */
@property --card-tint {
  syntax: '<color>';
  inherits: false;
  initial-value: transparent;
}

利用作用域可以做"局部主题岛",让页面某一块用不同配色而不影响全局:

cssCode
/* 全站浅色,但"促销专区"内部强制深色,只需在容器上覆盖语义令牌 */
.promo-zone {
  --color-bg: #111827;
  --color-text: #f9fafb;
  --color-accent: #f59e0b;
  background: var(--color-bg);
  color: var(--color-text);
}

多主题与系统主题协同

把"跟随系统"和"用户手动选择"结合,是主题系统的完整形态。

cssCode
:root {
  color-scheme: light;
  --bg: #fff;
  --fg: #111;
}
/* 用户没手动选时,跟随系统深色 */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme]) {
    color-scheme: dark;
    --bg: #111;
    --fg: #eee;
  }
}
/* 用户手动选择优先级最高 */
[data-theme='light'] { color-scheme: light; --bg: #fff; --fg: #111; }
[data-theme='dark']  { color-scheme: dark;  --bg: #111; --fg: #eee; }

body { background: var(--bg); color: var(--fg); }
javascriptCode
// 三态切换:light → dark → 跟随系统(auto)
function cycleTheme() {
  const el = document.documentElement;
  const cur = el.getAttribute('data-theme');
  const next = cur === 'light' ? 'dark' : cur === 'dark' ? '' : 'light';
  if (next) { el.setAttribute('data-theme', next); localStorage.theme = next; }
  else { el.removeAttribute('data-theme'); localStorage.removeItem('theme'); }
}
// 首屏尽早应用,避免闪烁(放在 <head> 内联脚本)
const t = localStorage.theme;
if (t) document.documentElement.setAttribute('data-theme', t);

性能与工程化考量

CSS 变量的更新只影响引用它的属性,代价通常很低;但把变量用在会触发重排的属性(如 width、grid-template)上并高频修改,仍可能引发布局抖动,动画尽量落在 transform/opacity 上。
高频(每帧)修改变量时务必用 requestAnimationFrame 节流,一次 pointermove 里 setProperty 多个变量比多次触发布局更划算。
变量本身不增加多少体积,但"到处内联 style 设变量"会让 HTML 变大,能用类切换就别每个元素写行内变量。
构建期可用 PostCSS 插件把变量对不支持环境降级(现代项目基本无需,覆盖率已 97%+)。

更多真实案例

案例 4:图表与页面同源配色。 某数据看板此前 CSS 和 ECharts 各维护一套色板,改主题时两边经常对不上。改为 JS 从 CSS 令牌读取颜色(getComputedStyle 读 --chart-1~--chart-8)传给图表后,主题切换时图表颜色自动同步,配色不一致的 bug 归零。

案例 5:A/B 测试快速换色。 某增长团队要对"主 CTA 按钮"做多组配色 A/B 实验。用变量把按钮色抽成 --cta-bg 后,实验平台只需注入一行 `:root{--cta-bg:#xxx}` 即可切换方案,无需为每个变体重新打包 CSS,实验上线周期从 2 天缩短到分钟级。

案例 6:错峰动画去 JS 化。 某官网首屏 12 个特性卡片原本用 JS 逐个 setTimeout 做入场动画,逻辑散乱且易抖动。改用 `--i` + `animation-delay: calc(var(--i)*60ms)` 后,删掉约 40 行 JS,动画交给合成线程更流畅。

数据与对比:变量方案与替代方案

| 需求 | CSS 变量 | 内联 style | 多份 CSS | class 切换 |

| --- | --- | --- | --- | --- |

| 运行时改主题 | 好(改根变量) | 差(要遍历元素) | 差(要重载) | 一般(预置好类) |

| 网络成本 | 低 | 低 | 高(多份文件) | 低 |

| 首屏闪烁 | 内联脚本可避免 | 无 | 有(切换重载) | 内联可避免 |

| 与 JS 交互 | 强 | 强但笨重 | 弱 | 一般 |

| 维护成本 | 低(单一数据源) | 高 | 高 | 中 |

用变量设计"可配置组件接口"

把组件对外可定制的点抽成局部变量,就像给组件定义了一组"CSS props"。使用者不必了解内部实现,只改这几个变量即可定制。

cssCode
/* 一个高度可配置的按钮组件 */
.btn {
  /* —— 公开接口(有默认值,外部可覆盖) —— */
  --btn-bg: var(--color-accent, #3b82f6);
  --btn-fg: #fff;
  --btn-radius: 8px;
  --btn-pad-y: 10px;
  --btn-pad-x: 18px;
  --btn-font: 14px;
  --btn-hover-bg: var(--color-accent-hover, #2563eb);

  /* —— 内部实现(用上面的变量拼装) —— */
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: var(--btn-pad-y) var(--btn-pad-x);
  background: var(--btn-bg);
  color: var(--btn-fg);
  border: none;
  border-radius: var(--btn-radius);
  font-size: var(--btn-font);
  cursor: pointer;
  transition: background 0.2s;
}
.btn:hover { background: var(--btn-hover-bg); }

/* 定制变体:只覆盖公开接口,零重复样式 */
.btn--lg     { --btn-pad-y: 14px; --btn-pad-x: 26px; --btn-font: 16px; }
.btn--pill   { --btn-radius: 999px; }
.btn--danger { --btn-bg: #ef4444; --btn-hover-bg: #dc2626; }
.btn--ghost  { --btn-bg: transparent; --btn-fg: var(--color-accent); }

同理可做一个卡片组件的可配置接口:

cssCode
.card {
  --card-pad: 20px;
  --card-radius: 12px;
  --card-bg: var(--color-bg, #fff);
  --card-shadow: 0 1px 3px rgba(0,0,0,.1);
  --card-accent: var(--color-accent, #3b82f6);

  padding: var(--card-pad);
  border-radius: var(--card-radius);
  background: var(--card-bg);
  box-shadow: var(--card-shadow);
  border-top: 3px solid var(--card-accent);
}
.card--flat      { --card-shadow: none; border: 1px solid #eee; }
.card--compact   { --card-pad: 12px; }
.card--warning   { --card-accent: #f59e0b; }

变量 + 容器查询:组件级自适应令牌

媒体查询看视口,容器查询看父容器。把令牌放进容器查询里改,能让同一组件在不同宽度的容器里自动切换密度。

cssCode
.card-host {
  container-type: inline-size;
  container-name: card;
}
.card {
  --card-pad: 12px;
  --card-cols: 1;
  padding: var(--card-pad);
  display: grid;
  grid-template-columns: repeat(var(--card-cols), 1fr);
  gap: var(--card-pad);
}
/* 容器够宽时,同一组件自动变双列、加大内边距 */
@container card (min-width: 480px) {
  .card { --card-pad: 20px; --card-cols: 2; }
}

不用 Sass:纯 CSS 变量派生工具类系统

结合变量与 calc,可以在纯 CSS 里搭出轻量的间距/字号工具类,无需预处理器循环。

cssCode
:root {
  --space: 4px;
}
/* 间距工具类:值 = 基准 × 档位 */
.p-1 { padding: calc(var(--space) * 1); }
.p-2 { padding: calc(var(--space) * 2); }
.p-3 { padding: calc(var(--space) * 3); }
.p-4 { padding: calc(var(--space) * 4); }
.p-6 { padding: calc(var(--space) * 6); }
.p-8 { padding: calc(var(--space) * 8); }

.gap-2 { gap: calc(var(--space) * 2); }
.gap-4 { gap: calc(var(--space) * 4); }

/* 密度切换:改一个 --space,全套工具类同时缩放 */
.compact { --space: 3px; }   /* 紧凑模式 */
.cozy    { --space: 6px; }   /* 宽松模式 */

只要在某个容器上切 `.compact`/`.cozy`,其内部所有工具类的实际间距就跟着变——这是 Sass 编译期变量做不到的运行时能力。

变量在渐变、阴影、SVG 中的应用

变量不只用于单个色值,还能作为渐变、阴影、滤镜、甚至 SVG 属性的组成部分。

cssCode
:root {
  --grad-from: #6366f1;
  --grad-to: #ec4899;
  --grad-angle: 135deg;
}
.hero {
  background: linear-gradient(var(--grad-angle), var(--grad-from), var(--grad-to));
}

/* 多层阴影用变量组合,做"高度系统" */
:root {
  --elev-1: 0 1px 2px rgba(0,0,0,.08);
  --elev-2: 0 4px 8px rgba(0,0,0,.12);
  --elev-3: 0 10px 24px rgba(0,0,0,.16);
}
.dialog { box-shadow: var(--elev-3); }
.dropdown { box-shadow: var(--elev-2); }

内联 SVG 也能读取 CSS 变量(需通过 CSS 设置 fill/stroke):

cssCode
/* 图标颜色跟随主题令牌,一处改色所有图标同步 */
.icon { fill: var(--color-accent, currentColor); }
htmlCode
<svg class="icon" viewBox="0 0 24 24" width="24" height="24">
  <path d="M12 2 2 22h20L12 2z" />
</svg>

变量、unset 与 revert

理解变量与全局关键字的关系有助于排查"变量突然失效"。

`initial`:把自定义属性重置为"保证无效"的空状态(对未注册变量而言),var() 会走 fallback。
`inherit`:显式取父级该变量的值。
`unset`:可继承属性等于 inherit,不可继承属性等于 initial。
`revert`:回退到浏览器/用户样式表的值。
cssCode
.reset-scope {
  /* 清掉本作用域的某个令牌,让内部回退到 fallback 或继承 */
  --card-accent: initial;
}
.card { border-top: 3px solid var(--card-accent, #ccc); } /* 取到 #ccc */

命名规范详解

好的命名能让令牌系统自解释。推荐 `--[类别]-[角色]-[状态/变体]` 的结构。

| 类别前缀 | 示例 | 说明 |

| --- | --- | --- |

| color | --color-text-muted | 颜色语义令牌 |

| space | --space-md | 间距档位 |

| fs / font | --fs-lg | 字号 |

| radius | --radius-pill | 圆角 |

| shadow / elev | --elev-2 | 阴影/层级 |

| z | --z-modal | 层叠顺序 |

| dur / ease | --dur-fast、--ease-out | 动效时长与曲线 |

cssCode
:root {
  --z-dropdown: 1000;
  --z-sticky: 1020;
  --z-modal: 1050;
  --z-toast: 1080;
  --dur-fast: 0.15s;
  --dur-base: 0.3s;
  --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
}
.modal { z-index: var(--z-modal); transition: opacity var(--dur-base) var(--ease-out); }

避免的命名: 表象命名(--blue、--big)会在换主题时变得自相矛盾(深色主题里 --blue 可能其实是浅色);应始终按"角色/语义"命名。

从 Sass 变量迁移到 CSS 变量

老项目常满是 `$` 变量,迁移不必一步到位,可采用"桥接"策略:保留 Sass 变量做编译期逻辑,同时把需要运行时可变的值输出为 CSS 变量。

scssCode
// 保留 Sass map 做编译期批量生成
$palette: (
  'primary': #3b82f6,
  'danger':  #ef4444,
);

// 桥接:把 Sass 值写进 CSS 变量,运行时就能改
:root {
  @each $name, $color in $palette {
    --color-#{$name}: #{$color};
  }
}

// 组件里用 CSS 变量(运行时可换肤),而不是直接用 $ 变量
.btn { background: var(--color-primary); }

迁移判断标准很简单:这个值需要运行时变吗? 需要(主题、用户设置、交互)就迁到 CSS 变量;只在构建期用(循环生成、条件编译、函数计算)就留在 Sass。

常见坑(补充)

8.变量拼接单位失败:`--w: 50` 后 `width: var(--w)px` 无效,应存 `--w: 50px` 或写 `calc(var(--w) * 1px)`。
9.在 @media 条件里用 var():`@media (min-width: var(--bp))` 不生效——媒体查询条件里不能用自定义属性(可用 Sass 或 @custom-media 替代)。
10.误以为 fallback 能兜非法值:如前所述,fallback 只兜"未定义",非法值走 IACVT,需靠 @property 类型约束。
11.忘记 color-scheme 导致表单控件不跟主题:深色令牌设了,但滚动条/复选框还是浅色,加 `color-scheme: dark`。
12.高频 setProperty 未节流:滚动/鼠标事件里直接同步改变量易掉帧,用 requestAnimationFrame 包裹。

变量做状态驱动样式(无需切多个 class)

把状态编码进变量,能用一个变量驱动一组视觉变化,让 JS 逻辑更简洁。

cssCode
.uploader {
  --p: 0;                 /* 上传进度 0~1,由 JS 更新 */
  background: linear-gradient(
    to right,
    var(--color-accent) calc(var(--p) * 100%),
    #e5e7eb 0
  );
}
.uploader[data-state='error']   { --color-accent: #ef4444; }
.uploader[data-state='success'] { --color-accent: #22c55e; }
javascriptCode
// JS 只维护一个数值,视觉全交给 CSS
function setProgress(el, ratio) {
  el.style.setProperty('--p', ratio);
  el.dataset.state = ratio >= 1 ? 'success' : 'uploading';
}

总结

| 你的需求 | 用 CSS 变量怎么做 |

| --- | --- |

| 全站统一主色 | :root 定义 --color-primary,处处 var() |

| 深色/换肤 | [data-theme] 覆盖同名变量 |

| 响应式间距/列数 | 媒体查询里改 :root 变量 |

| 动态/交互样式 | JS setProperty 改变量 |

| 组件可定制接口 | 组件内定义局部变量,外部覆盖 |

| 变量参与动画 | @property 注册类型后再 animate |

| 编译期批量生成 | 交给 Sass,不用 CSS 变量 |

| 组件可配置接口 | 组件内定义局部令牌,变体只覆盖令牌 |

| 变量参与平滑动画 | @property 注册类型后 transition/animation |

| 兜住非法值 | @property 约束类型,而非 var() fallback |

| 局部主题岛 | 容器上覆盖语义令牌,不影响全局 |

| 图表与页面同源配色 | JS getComputedStyle 读令牌传给图表库 |

| 密度/紧凑模式 | 改一个 --space 基准,工具类整体缩放 |

最佳实践清单(补充)

令牌分三层:primitive(调色板)→ semantic(语义)→ component(组件),换肤只动语义层。
语义命名而非表象命名:`--color-text-muted` 优于 `--gray`。
需要动画的变量一律用 `@property` 注册类型,顺带获得类型校验。
高频修改用 requestAnimationFrame 节流,动画落在 transform/opacity 上。
首屏用 `` 内联脚本尽早应用主题,避免深浅色闪烁。
深色主题记得配 `color-scheme`,让原生控件跟随。
组件对外暴露"CSS 变量接口",变体只覆盖接口变量,杜绝重复样式。
Sass 与 CSS 变量分工:编译期逻辑用 Sass,运行时可变值用 CSS 变量。

一句话记忆:Sass 变量是"编译期的常量",CSS 变量是"运行时的字段"。凡是需要跟随主题、断点、用户交互动态变化的值,就交给 CSS 变量;需要在构建时做循环、函数、复杂计算的,才交给预处理器变量。二者搭配,才是现代样式工程的完整拼图。