配了三年 CSS Variables,今天才发现它们从来就是”哑巴”——@property 把这件事彻底变了

你写 transition: --hue 0.5s,浏览器没有任何反应。你写 @keyframes spin { to { --angle: 360deg; } },动画跳着走而不是转着走。

这不怪你。CSS 自定义属性从第一天起就是”哑巴”——浏览器只知道它是个字符串,不知道它是数字、颜色还是角度,自然不知道怎么过渡、怎么插值。

CSS Houdini 的 @property 规则,就是来解决这个问题的。


旧世界:变量是字符串,不是值

传统写法是这样的:

.card {
  --hue: 0;
  background: hsl(var(--hue) 70% 50%);
  transition: --hue 0.5s ease;
}
.card:hover {
  --hue: 200;
}

你期待的是色相平滑转动。实际效果:从 0 直接跳到 200,没有任何过渡。

浏览器看到的是两个”字符串”——它不知道你在操作一个数字。


@property:给变量一个身份证

用 @property 注册之后,浏览器才真正”认识”这个变量:

@property --hue {
  syntax: "<number>";
  inherits: false;
  initial-value: 0;
}

.card {
  --hue: 0;
  background: hsl(var(--hue) 70% 50%);
  transition: --hue 0.5s ease;
}
.card:hover {
  --hue: 200;
}

三行注册换来平滑过渡。

syntax 告诉浏览器值的类型,支持 <number><length><color><percentage><angle><transform-function>,也可以用 | 组合。

inherits 控制是否向下传递。设为 false,这个值只属于当前元素,不会污染子元素。

initial-value 是必填项(syntax 为 * 除外),也是它的默认值。


真正改变游戏规则的两个场景

1. 渐变色也能过渡

渐变色本质上是一个 background-image,传统 CSS 根本没法过渡。但把颜色拆成两个变量分别注册:

@property --from {
  syntax: "<color>";
  inherits: false;
  initial-value: oklch(65% 0.2 30);
}
@property --to {
  syntax: "<color>";
  inherits: false;
  initial-value: oklch(65% 0.2 260);
}

.card {
  background: linear-gradient(135deg, var(--from), var(--to));
  transition: --from 0.6s ease, --to 0.6s ease;
}
.card:hover {
  --from: oklch(75% 0.25 140);
  --to: oklch(55% 0.3 300);
}

现在 hover 时整个渐变在 OKLCH 空间里平滑插值——在 sRGB 空间里混色会偏脏,OKLCH 不会出现这个问题。

2. 角度旋转动画

之前想做一个旋转边框,只能用伪元素加 conic-gradient hack。现在直接:

@property --angle {
  syntax: "<angle>";
  inherits: false;
  initial-value: 0deg;
}

@keyframes spin-border {
  to { --angle: 360deg; }
}

.spinning-border {
  background: conic-gradient(
    from var(--angle),
    oklch(70% 0.3 0),
    oklch(70% 0.3 120),
    oklch(70% 0.3 240),
    oklch(70% 0.3 0)
  );
  animation: spin-border 3s linear infinite;
}

浏览器知道 –angle 是角度,@keyframes 直接驱动它,而不是 hack 一个 transform: rotate()


inherits: false 的正确用法

很多人忽略了 inherits: false,觉得默认值 true 也没关系。但在组件级设计系统里,这个区别很关键:

@property --badge-scale {
  syntax: "<number>";
  inherits: false;
  initial-value: 1;
}

.badge {
  --badge-scale: 1;
  transform: scale(var(--badge-scale));
  transition: --badge-scale 0.2s ease;
}
.badge:active {
  --badge-scale: 0.9;
}

设为 false 之后,每个 badge 实例有独立的 –badge-scale,修改一个不会影响其他实例。

设为 true(默认),父元素改变这个值,所有子元素都会跟着变——在复杂组件树里这是灾难。


类型守卫:无声的保护

@property 注册时带了 syntax,浏览器就有了类型约束能力。

@property --size {
  syntax: "<length>";
  inherits: false;
  initial-value: 16px;
}

.button {
  --size: 16px;
  font-size: var(--size);
}
.button--large {
  --size: large; /* 语法错误,静默忽略,保留初始值 16px */
}

传入不符合 syntax 的值时,浏览器会静默忽略,使用 initial-value,而不是像以前一样接受任何字符串导致难以追踪的 bug。


浏览器支持情况(2026年)

Chrome 100+、Edge 100+、Safari 16.4+ 已全面支持。Firefox 128+ 也已支持。全球覆盖率超过 90%。

对于旧浏览器,写一个静态值在前面做 fallback:

.button {
  font-size: 16px; /* fallback */
  font-size: var(--size); /* 现代浏览器覆盖 */
}

或者用 @supports:

@supports (background: conic-gradient(red, blue)) {
  .card {
    background: conic-gradient(var(--from), var(--to));
  }
}

实际迁移路径

如果你现在有一套设计令牌系统,迁移步骤:

第一步:把需要动画或过渡的变量抽出来注册。

@property --brand-hue { syntax: "<number>"; inherits: false; initial-value: 260; }
@property --brand-chroma { syntax: "<number>"; inherits: false; initial-value: 0.25; }
@property --gradient-angle { syntax: "<angle>"; inherits: false; initial-value: 135deg; }

第二步:旧代码继续工作,所有未注册的变量行为不变。

第三步:在需要动画或类型保护的地方,逐步切换到注册版本。


三年了,CSS 变量一直是”哑巴字符串”。@property 让它开口说话——知道自己是数字还是颜色,能过渡,能受保护。

这件事,早该变了。

评论区

0 条评论

登录后可评论。