把滚动进度写进 CSS 变量去驱动别的属性,动画直接罢工。不是变量坏了------是它压根没资格参与插值。今天看一下
@property。
0. 两行代码,动画罢工
css
.bar {
width: calc(var(--p) * 1%);
background: #16a34a;
transition: --p 1s linear; /* 我就是要对变量做过渡,怎么了? */
}
js
el.style.setProperty('--p', '0');
getComputedStyle(el).width; // 触发一次样式计算(注意:这并不能真正锁住起始值)
el.style.setProperty('--p', '100');
transition 写一个属性名而已,--p 也是属性名,语法完全合法,编辑器也不报错。然后屏幕就出现了进度条瞬间满格,一帧过渡都没有。 demo测试:
css
[T1] --q 未注册 变化节点 1 个: [[0, "100"]]
判定: 秒跳(transition 无效,值瞬时到位)
这个轨迹变化节点只有 1 个,而且第一帧就是 "100" ------连「从 0 显示一帧」的机会都没给,.setProperty() 的那一刻直接跳到终点。这并不是 transition 效果不好,是压根没工作。 顺带一提:getComputedStyle().width 返回的是 resolved 值,对未注册变量驱动的属性,这个动作并不能「锁住起始值」,实际当中需要注意。
1. @property,注册变量
css
@property --p {
syntax: '<number>'; /* 类型:这是个数 */
inherits: false; /* 继承:不往下传 */
initial-value: 0; /* 初始值:没设置时兜底 */
}
注册之后,--p 就从「字符串占位符」变成了有类型、有初始值、有继承语义的真属性 。同样的 transition: --p 1s linear,现在开工了:
less
[T2] --p <number> 变化节点 61 个: [[0, "0"], [17, "1.67"], [33, "3.34"],
[50, "5"], [67, "6.66"], [83, "8.34"], ...(共 61 个节点,每帧稳定步进约
+1.67,精确线性插值到 [1000, "100"],完整轨迹见 验证输出.txt)
判定: 逐帧插值 ✓
60fps 采样一秒拿到 61 个节点,步进均匀------这不是看起来动了,是逐帧可查的插值轨迹。可以在浏览器里面运行一下试试看效果,看多少遍都不如跑一遍。
支持度(截至发稿):Chrome 85+、Safari 16.4+、Firefox 128+,Baseline 2024 全绿,放心用。 
但检测的时候别用 CSS.supports:
python
[ENV] CSSPropertyRule 接口: True | CSS.supports("@property --x: 0"): False
| CSS.registerProperty: True
CSSPropertyRule 接口存在、注册功能一切正常,CSS.supports("@property --x: 0") 却返回 False,那是因为 CSS.supports 只认属性声明,不认识 at-rule。
要检测的话就查 typeof CSSPropertyRule !== 'undefined'。
2. 变量为什么天生不能插值
普通 CSS 变量只是一个文本替换 :var(--p) 在解析阶段被替换成字面的 token 串,然后浏览器才去理解这条声明。
也就是说在插值器眼里,--p 的值可能是 100、可能是 red、可能是 hello world,它是一段没有类型的字符串,而字符串没有中间值。
从 0 到 100 的数字插值需要知道这是数,中间态是 0.5、1.5、2.5;从红到蓝的颜色插值需要知道这是颜色,中间态是紫色。不知道类型,插值器只能罢工,transition 规范干脆把未注册的自定义属性标记为离散动画。 @property 的本质就是把类型声明补上:syntax 告诉浏览器值长什么样,插值器立刻恢复工作。它属于 CSS Houdini 的 Properties and Values API------JS 侧还有个等价的 CSS.registerProperty(),但 CSS 里能写的就别动 JS,@property 在样式表加载时就生效,不会闪一下未注册成功时候的状态,最厉害的是渐变的色标也能逐帧插。
css
@property --c1 { syntax: '<color>'; inherits: false; initial-value: #ef4444; }
@property --c2 { syntax: '<color>'; inherits: false; initial-value: #3b82f6; }
.gbox {
background: linear-gradient(90deg, var(--c1), var(--c2));
animation: hueShift 2s linear infinite alternate;
}
@keyframes hueShift {
from { --c1: #ef4444; --c2: #3b82f6; }
to { --c1: #facc15; --c2: #16a34a; }
}
1 秒采样,background-image 出现 62 种不同插值:
css
[T3] backgroundImage 不同插值 62 种(1 秒内)
[T3] 首帧: linear-gradient(90deg, rgb(247, 165, 34), rgb(32, 154, 123))
[T3] 末帧: linear-gradient(90deg, rgb(248, 173, 32), rgb(30, 156, 113))
判定: 颜色逐帧插值 ✓
(动画是无限循环,采样时已在途中,所以首帧不是 from 色------62 种不同值本身就是逐帧在变的铁证。)
当然,传统做法用 keyframes 直接动画 background-position 或 opacity 也能近似部分渐变效果,@property 并非唯一解;但像 conic-gradient 的角度旋转这类,纯 CSS 就只有 @property 能做到了。
顺便把上篇遗留的内容也一起说了:滚动进度写进注册过的 --p,就能继续驱动任何属性------calc(var(--p) * 1px) 平移、opacity: calc(var(--p) / 100) 淡入,全跟手。这个场景里 inherits 建议设 true:祖先元素设置一次,所有后代元素都能消费,不用逐个赋值。
3. 误区
3.1 注册了 ≠ 能插值
syntax: '*' 是万能语法,什么值都能装。装完会发现照样秒跳:
css
[T4] --u(syntax:*)变化节点 1 个: [[0, "100"]]
判定: 照样秒跳------注册了 ≠ 能插值,类型才是门票
'*' 的值是任意 token 串,能兜底初始值、能控制继承,但插值还是免谈。想动画,就老实声明具体类型。
3.2 initial-value 写错,整条规则都没用
syntax 不是 '*' 时,initial-value 必填,而且必须能被该类型解析。我故意写了条缺 initial-value 的 --bad,然后翻了一下 CSSOM:
less
[T5] CSSOM 中 CSSPropertyRule 列表: ["--p", "--u", "--c1", "--c2", "--reg", "--inh", "--noinh"]
[T5] --bad 在 CSSOM 中? False
七个合法注册全在,--bad 找不到,也没有报错和警告、控制台干净得像什么都没发生,规则整条被丢弃。
4. 自带能力
4.1 没值也有值
未注册变量没设置就是空串;注册过的变量没设置,读出来是 initial-value 的计算值:
csharp
[T6] 未设置时读 --reg(注册 <color> initial teal): "rgb(0, 128, 128)"
[T6] 未设置时读 --never-set(从未声明) : ""
两个细节:
- 兜底真的存在(
var()可以少写一层 fallback); teal读出来是rgb(0, 128, 128),注册值会按类型规范化,不是原样存取的字符串。做主题切换时这是个省心设计,但也别拿它当原始字符串比对。
4.2 inherits 两态,行为对得上直觉
css
@property --inh { syntax: '<number>'; inherits: true; initial-value: -1; }
@property --noinh { syntax: '<number>'; inherits: false; initial-value: -1; }
父元素把两个都设成 7,子元素读:
csharp
[T7] 子元素读 --inh(inherits:true) = "7"
[T7] 子元素读 --noinh(inherits:false)= "-1"
false 的子元素拿到的不是空串,是 initial-value -1,不继承,有兜底值。 最后提醒一句性能:注册变量被大量元素引用时,逐帧插值会连带触发所有依赖属性的样式计算,重绘成本可能高于直接动画属性本身。小范围使用没问题,全页面铺开前先掂量一下。
面试题
Q1:为什么未注册的 CSS 变量不能参与 transition/animation? 普通自定义属性的值替换发生在解析阶段,插值器拿到的是无类型的 token 串------没有「中间值」概念,规范将其归为离散动画(直接跳变)。@property 通过 syntax 补上类型声明后,浏览器才能计算中间态,逐帧插值。这也是渐变色动画(色标是 <color> 类型)能纯 CSS 实现的前提。 Q2:syntax: '*' 和 <number> 注册的变量,行为差异是什么? '*' 是万能语法:接受任意 token 串、可省略 initial-value,但值不可插值(等于有兜底的未注册变量)。<number> 等具体类型:initial-value 必填且须匹配类型、非法值声明被丢弃,但 transition/animation/keyframes 全部可用。选择原则:要动画就声明具体类型,只做兜底和继承控制用 '*'。 Q3:@property 注册失败(如缺 initial-value)会怎样?如何排查? 整条规则被静默丢弃:CSSOM 中不出现对应的 CSSPropertyRule,变量退回未注册行为(空值、不插值、var() 走 fallback),全程无报错。排查方法:遍历 document.styleSheets 的 cssRules 查 CSSPropertyRule 实例;或读一个未显式设值的元素,注册成功返回 initial-value 计算值,失败返回空串。注意 CSS.supports('@property --x: 0') 返回 False 不代表不支持------它只认属性声明。
参考
- MDN:@property、CSS Properties and Values API
- Chrome for Developers:@property(Bramus 系列)
- Baseline 2024(Chrome 85+ / Safari 16.4+ / Firefox 128+,截至发稿)
demo 也已经整理好了,公众号「程序员蜡笔熊」回复「property」拿走。
预告 :变量注册的时候既然能存值,那么能不能存逻辑呢?CSS 开始长出自己的函数(@function),传参、复用、按条件返回值,结束 calc() 拼参数拼到吐。下篇看看它到底能干啥、还差啥。
期待您的评论和建议~