我一直觉得CSS里的普通变量有个让人抓狂的毛病:它没法“渐变”。你定义了一个--x: 1,然后想把它从1过渡到100,结果页面瞬间变成100,中间一步都没有。尤其是想用CSS变量驱动动画或者绘制进度环的时候,你只能借助JavaScript去逐帧更新。
但你要是用了@property这个进阶能力,情况就完全不一样了。它相当于给自定义属性开了个后门,让浏览器能真正理解这个变量到底是一个颜色、一个角度,还是一个数字。一旦理解了,它就会帮你在过渡和动画的每一帧里“插值”。
这篇文章我不会只贴一段炫技代码,而是拿两个我能想到最常用的场景,带你把@property用明白。
先说说普通变量到底输在哪儿
看这个例子:
:root {
--scale: 1;
--color: #ff0000;
}
.el {
transform: scale(var(--scale));
transition: --scale 0.3s ease;
}
.el:hover {
--scale: 1.5;
}
你觉得鼠标悬停时,--scale会从1平滑变化到1.5吗?并不会。因为浏览器默认自定义属性是“字符串”,无法判断该用什么方式去插值。它只知道起始值是"1",结束值是"1.5",字符串没有中间状态,那干脆直接切换。
@property的用法是让你提前告诉浏览器这个变量是什么类型。比如:
@property --scale {
syntax: "<number>";
inherits: false;
initial-value: 1;
}
声明了syntax是数字之后,浏览器才会把--scale当作真正的数值去处理,碰到transition或animation,就能计算每一帧的绝对值了。
案例1:一个流畅的渐变色旋转
前几年有个非常火的“旋转边框”效果:一个容器外面有一圈渐变色,并且这圈渐变还像跑马灯一样不断转。传统做法是用伪元素加强几个动画,或者用background-position硬撑。实际上用@property配合<angle>,能让整个事情简单且顺滑很多。
基础HTML长这样:
<div class="gradient-card">Houdini 让渐变活起来</div>
先注册一个角度变量:
@property --angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
然后给卡片用上这个变量:
.gradient-card {
--angle: 0deg;
width: 260px;
padding: 20px;
text-align: center;
border-radius: 20px;
background: linear-gradient(100deg, #8b5cf6, #ec4899, #f59e0b);
/* 这里我们先用普通底色占位,后面用伪元素把渐变转起来 */
}
.gradient-card::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: linear-gradient(var(--angle), #8b5cf6, #2dd4bf, #f43f5e);
z-index: -1;
}
接着只写一个动画:
.gradient-card {
animation: spin 3s linear infinite;
}
@keyframes spin {
from {
--angle: 0deg;
}
to {
--angle: 360deg;
}
}
如果不用@property,这个--angle的动画是无法生效的。现在你把代码跑一下,能看到那一圈渐变真的像水波一样平滑转起来了。
实际工作中,这种渐变卡片可以放在登录页、标题背景,还有人拿它做加载进度条。
案例2:数字滚动动画,这次不发散
做数据可视化页的时候,经常会遇到一个需求:统计数字从0开始滚到某个目标值。以前我第一时间想到的是引入一个第三方动画库,或者用requestAnimationFrame算。现在用原生CSS就能搞定,而且效果特顺滑。
要滚动的数字在HTML里是纯语义标签:
<span class="counter" style="--target: 100"></span>
CSS侧思路是这样的:把--target设定为目标值,再用@property注册一个内部计数变量--num,用animation把它从0过渡到100,最后通过counter-reset把--num显示出来。
先注册变量:
@property --num {
syntax: "<integer>";
inherits: false;
initial-value: 0;
}
然后这样写计数器:
.counter {
counter-reset: num var(--num);
font-size: 60px;
font-weight: 700;
animation: number-count 2s ease-out forwards;
}
.counter::after {
content: counter(num);
}
@keyframes number-count {
from {
--num: 0;
}
to {
--num: 100;
}
}
这里面微妙的地方在于counter-reset可以接收CSS变量作为值。当--num在动画中被浏览器计算成一个个整数,比如23.6,由于syntax是<integer>,浏览器会自动取整,然后把它显示在伪元素里。
如果你想显示“1234”这种更复杂的数字,只需要把目标值写到HTML中,再配合animation-delay或者给不同元素设置不一样的目标值。举个常见的带进度数字的按钮:
<button style="--target: 85"></button>
.download-btn {
--target: 85;
counter-reset: downloadNum var(--num);
}
.download-btn::after {
content: counter(downloadNum) "%";
animation: downloadAnim 1.5s ease-out forwards;
}
@keyframes downloadAnim {
from { --num: 0; }
to { --num: var(--target); }
}
这里又有一个比较现代的点:动画的结束值可以是另一个普通CSS变量。因为--target在按钮上通过内联方式设置了,而--num是一个已注册的整数,两者可以互通。
背景颜色过渡的进阶玩法
类似的,如果你想在鼠标悬停时,让某一个块从纯蓝色平滑过度到青色,通常你得写transition: background 0.3s。但有时候你是用两个半透明的渐变叠加,你会发现背景颜色过渡不够细腻。这时候用@property把颜色变量也注册成<color>类型:
@property --bg-start {
syntax: "<color>";
inherits: false;
initial-value: #3498db;
}
@property --bg-end {
syntax: "<color>";
inherits: false;
initial-value: #2ecc71;
}
然后你可以定义一组背景色,并在hover时改变值
.btn {
background: linear-gradient(135deg, var(--bg-start), var(--bg-end));
transition: --bg-start 0.3s ease, --bg-end 0.3s ease;
}
.btn:hover {
--bg-start: #e74c3c;
--bg-end: #f39c12;
}
单看transition这行你可能觉得神奇属性怎么还能直接transition?其实不是的,transition的属性名可以是自定义属性。前提是它被@property注册成合法类型。如果没注册,写什么都不好使。
兼容性以及回退方案
现在的Chrome、Edge和Safari都已经支持了@property,Firefox也从2024年开始对应实现了。不过要在生产环境用的话,我建议你加一个柔和降级策略。用一个@supports判断浏览器是否认得@property的语法:
@supports (syntax: "<color>") {
@property --color-primary {
syntax: "<color>";
inherits: false;
initial-value: #4a90e2;
}
}
但@supports不能完全检测一个变量有没有注册成功。真正稳妥的办法是先用CSS变量设置默认值,然后在支持@property的环境里覆盖注册,并提供一套无需动画的静态样式。例如数字滚动,如果不支持,那至少显示最终结果,不要让人等一个不会跳动的0。
注册属性时要注意的几个坑
忍不住想提醒几件事,都是我自己踩过的。
- 你在CSS中写
@property时,所有声明都必须是有效的。如果语法字符串写错,比如把<number>写成了number,那整个属性会被忽略,且浏览器会告诉你Property '--xxx' registration failed。 inherits不是填不填都行。如果你把inherits: true,那么子元素会自动继承这个变量,类似普通CSS变量。但如果你不想让子元素也跟着变化,就声明false,然后自己给需要的元素设置独立值。- 注册后
initial-value是该变量的默认值。假如你在动画里设置了from没有设置to,那结束时会回到initial-value。 - 一个比较隐晦的点:动画过程中,浏览器为了让变量按照数值插值,会在每一帧对变量做“周期性转换”。所以你最好不要用在对性能极其敏感的页面里大量使用。几十个元素的数字动画还好,但如果你给几千个DOM同时做渐变旋转,CPU可能会冒烟。
用JavaScript动态注册也是后路
如果你不想把所有属性都写在CSS顶层,也可以选择在JS里注册:
if ("registerProperty" in CSS) {
CSS.registerProperty({
name: "--progress",
syntax: "<percentage>",
inherits: false,
initialValue: "0%"
});
} else {
// Houdini不支持时你还能做点什么
}
这里注意JS里字段是initialValue(驼峰),别写错成CSS风格的initial-value。
但JS注册有一个时序问题:如果注册发生在样式计算之后,那么某些已经应用的动画不会重新触发生效。所以尽量在页面加载早期、任何样式使用它之前完成注册。
我们能拿它做什么乱七八糟的事
我自己在业余项目里用@property做过一个非常装的仪表盘:数字不断滚动,且仪表盘的圆弧扫过时,圆弧的渐变颜色也跟着变化。一个复杂的状态以前既要用canvas又要用js动画,现在依靠几个注册变量和CSS动画,大约少了一半代码。
这种能力其实属于“CSS Houdini”的一部分。Houdini不是一个可以点击安装的插件,而是一系列让开发者能打开浏览器底层样式的API总称。@property是其中最先落地、也最容易上手的一个入口。
当我第一次看到数字计数器用原生CSS动画跑起来的时候,我心里想的是:“以后还是尽量让浏览器做它更擅长的事情吧。” 虽然JavaScript动画库依然很强大,但CSS动画能做的,让它自己做好,既省了主线程,又不容易出现闪烁。
写到最后
做前端总容易陷入“实现功能就去谷歌代码”的循环,但CSS的玩法更新速度可能比你想象的快。今天这个@property三年前我根本不敢想能这样用,现在它已经能进入实际项目了。如果你还不确定哪一步开始,建议先拿数字滚动这个案例改着玩,把syntax类型换成<integer>、<length>都试试。当你发现原来变量也能像SVG里的animate一样流畅变化,那种打开新世界大门的感觉挺好玩的。

