数据看板上那种「数字从 0 一路跳到 1280」的效果,过去基本是 JavaScript 的保留节目:写个 requestAnimationFrame,配一条缓动曲线,每帧把插值结果写进 textContent。不算难,但每个项目复制一遍也挺烦的,而且这类代码往往写着写着就长出了一堆边界处理。
现在的 CSS 已经能把这件事整个吃下来。需要的只有三样东西:@property、CSS 计数器、滚动驱动动画。下面从原理开始拆,最后给一份能直接抄走的完整代码。
三个零件,缺一不可
先给结论,后面再展开:
@property把一个自定义属性从「不可插值的字符串」升级成「可以补间的整数」;counter-reset配合counter(),把这个整数输出成页面上的文字;animation-timeline: view()让动画进度跟着滚动位置走,而不是跟着时间走。
少任何一个,这套方案都跑不起来。
一、为什么自定义属性默认动不起来
如果你直接这么写:
@keyframes bad {
from { --n: 0; }
to { --n: 1280; }
}
页面上的结果是从头到尾一点变化都没有,或者干脆在某个瞬间从 0 直接跳到 1280。原因在于,未被 @property 注册的自定义属性,在浏览器眼里只是一串未经解析的 token,跟一个字符串没什么区别。两个字符串之间没法做数值插值,动画系统只能退化成「离散跳变」——也就是在某个进度点直接换值。
@property 的作用就是提前把类型交代清楚:
@property --count {
syntax: "<integer>";
initial-value: 0;
inherits: true;
}
这条规则有几个地方容易写错:
syntax的值必须是带尖括号的合法类型字符串,写成"integer"整条规则会被静默丢弃,不报错也不生效;initial-value必须能匹配上声明的语法,整数就写0,别加引号;inherits决定子元素能不能继承到动画中间的值。这里我们打算把动画挂在卡片上、在子元素里读值,所以必须给true。
注册完成之后,--count 就参与了正常的整数插值流程。有意思的是,<integer> 的插值结果会被取整到整数——浏览器每一帧会把算出来的值四舍五入。这个特性放在别的场景可能是缺点,放在计数器上恰好就是我们想要的跳字效果,不用额外做任何处理。
二、把整数渲染成页面上的文字
接下来的问题是:CSS 没有提供「把变量值当文本输出」的能力。content: var(--count) 这种写法是行不通的,content 只接受字符串、图片和计数器,不认识数字。
绕过去的办法是借用计数器:
.stat-card__value {
counter-reset: n var(--count);
}
.stat-card__value::after {
content: counter(n);
}
counter-reset 在这里做的事是把名为 n 的计数器设成 --count 的当前值——注意它虽然叫 reset,但接受任意整数,不只是重置为零。然后 counter(n) 把这个值渲染出来。
计数器的作用域规则是「从声明它的元素开始向下」。所以 counter-reset 写在元素本体、counter() 写在它自己的伪元素里,是能正常工作的。
另外记得加上 font-variant-numeric: tabular-nums。等宽数字可以避免数字在跳变过程中左右抖动——很多等宽字体之外的字体里,1 的宽度和 8 是不一样的,数字位数一变整块文本就会晃。
三、让滚动位置充当时间轴
最后一步,把动画的驱动源从「时间」换成「滚动位置」:
.stat-card {
animation: stat-in linear both;
animation-timeline: view();
animation-range: entry 15% cover 40%;
}
@keyframes stat-in {
from { --count: 0; }
to { --count: 1280; }
}
view() 的意思是:这张卡片自身的可见性进度,就是这条动画的时间线。卡片从视口下方冒头的那一刻是 0%,完全离开视口是 100%。
animation-range 则决定用这段可见性区间里的哪一截来跑动画。常用的区间名有四个,在「纵向滚动、元素比视口矮」的前提下:
entry:元素从下方开始进入,到完全进入结束;contain:元素完全落在视口内的那一段;exit:元素开始离开,到完全离开;cover:从开始进入一直到完全离开,是这几个里跨度最大的一个。
entry 15% cover 40% 读作:从「进入视口 15% 的位置」开始动,到「cover 区间的 40% 位置」结束。这个组合能让动画在卡片刚露出小半截的时候启动,滚动到画面中上部时收尾,节奏比较自然。
这里有个常见坑值得单独说:animation 简写会重置 animation-timeline。所以下面这段代码是无效的:
/* 错误示范:时间线被简写重置回了 auto */
.stat-card {
animation-timeline: view();
animation: stat-in linear both;
}
简写在 animation-timeline 之后出现,会把它重置成初始值 auto,动画就退化成按时间播放了。正确做法是把 animation-timeline 放在简写之后,或者像我后面那样单独放进 @supports 块里。
四、完整代码
把上面的片段拼起来,再加上一个和数字联动的进度条。为了让两个元素共享同一个动画进度,我额外注册了一个 --grow,由同一个关键帧一起驱动。
HTML 结构:
<div class="stat-card" role="img" aria-label="本月新增订单 1280 笔,目标达成率 78%">
<p class="stat-card__value"></p>
<p class="stat-card__unit">笔</p>
<div class="stat-card__track"></div>
</div>
注意 .stat-card__value 是个空元素,内容全部由 ::after 生成。
CSS:
/* ---------- 1. 注册可以补间的自定义属性 ---------- */
@property --count {
syntax: "<integer>";
initial-value: 0;
inherits: true;
}
@property --grow {
syntax: "<number>";
initial-value: 0;
inherits: true;
}
/* ---------- 2. 卡片本体 ---------- */
.stat-card {
display: grid;
grid-template-columns: 1fr auto;
align-items: baseline;
gap: 0 6px;
padding: 24px 28px;
border-radius: 16px;
background: #0f1218;
color: #eef2f8;
animation: stat-in linear both;
}
/* ---------- 3. 数字 ---------- */
.stat-card__value {
counter-reset: n var(--count);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 44px;
font-weight: 700;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.stat-card__value::after {
content: counter(n);
}
.stat-card__unit {
font-size: 14px;
color: #7c8798;
}
/* ---------- 4. 进度条 ---------- */
.stat-card__track {
grid-column: 1 / -1;
height: 6px;
margin-top: 18px;
border-radius: 999px;
background: #1c222d;
overflow: hidden;
}
.stat-card__track::after {
content: "";
display: block;
height: 100%;
border-radius: inherit;
transform-origin: left center;
transform: scaleX(var(--grow));
background: linear-gradient(90deg, #4f8cff, #8b5cf6);
}
/* ---------- 5. 关键帧 ---------- */
@keyframes stat-in {
from {
--count: 0;
--grow: 0;
}
to {
--count: 1280;
--grow: 0.78;
}
}
/* ---------- 6. 支持滚动驱动时接管时间线 ---------- */
@supports (animation-timeline: view()) {
.stat-card {
animation-timeline: view();
animation-range: entry 15% cover 40%;
}
}
/* ---------- 7. 不支持时的降级:按时间播一遍 ---------- */
@supports not (animation-timeline: view()) {
.stat-card {
animation-duration: 1.4s;
animation-delay: 0.2s;
}
}
/* ---------- 8. 尊重减少动效偏好 ---------- */
@media (prefers-reduced-motion: reduce) {
.stat-card {
animation: none;
--count: 1280;
--grow: 0.78;
}
}
五、几个真正会绊到人的点
inherits 必须为 true
动画写在 .stat-card 上,读值的是 .stat-card__value 和 .stat-card__track::after。如果 @property 里把 inherits 设成 false,子元素拿到的永远是 initial-value,页面上的数字会一动不动地停在 0。
无障碍要给一份兜底语义
纯 CSS 拼出来的数字,在无障碍树里的表现取决于浏览器和读屏软件的配合,不太可靠。稳妥的做法是给卡片容器加 role="img" 加 aria-label,把完整信息一次性说清楚,内部的装饰性文本自动被忽略。上面示例里的 aria-label 就是这个用途。
性能上有一个躲不开的成本
滚动驱动动画本身跑在合成器线程上,很轻。但计数器不是——counter-reset 的数值每次变化都会让文本内容重新布局和重绘,这部分开销没法绕开。所以这东西适合用在页面上零星几个关键数据上,别拿它去渲染一个几十行的表格。真到了那个量级,还是交给 JavaScript 更划算。
滚动容器别搞错
view() 默认找最近的滚动容器。如果卡片外面还套了一层 overflow: auto 的滚动区域,时间线就会挂在那个容器上,而不是整个页面。这个行为大多数时候是对的,但如果发现动画触发时机不对,先检查一下是不是多了一层滚动祖先。
六、浏览器支持与降级
@property 已经全线可用:Chrome 85+、Safari 16.4+、Firefox 128+ 都支持。
滚动驱动动画这边稍微滞后一点:Chrome 和 Edge 从 115 开始支持,Safari 26 跟上了,Firefox 目前还没默认开启。
所以降级方案是必须的。上面代码里用了两个 @supports 分支:支持滚动时间线时接管时间线,不支持时退回成一条普通的定时动画。不写降级的话,那些浏览器里动画时长为 0,数字会直接跳到终值——虽然不算难看,但白白丢掉了一个动画机会。
七、还能往哪扩
这套「注册属性 + 关键帧」的组合,本质上是给 CSS 开了一个可以自由计算的数值通道。除了数字,同一个 --grow 还能顺手驱动别的东西:
- 用
color-mix(in oklch, #4f8cff calc(var(--grow) * 100%), #8b5cf6)让进度条颜色随进度偏移; - 用
conic-gradient配--grow做环形进度; - 给多个卡片设置不同的
animation-range起点,滚动时依次点亮,比统一触发要耐看。
再往下想,把 animation-timeline 从 view() 换成 scroll(),时间线的主体就从「某个元素何时可见」变成了「整个滚动容器滚了多远」,适合做页面顶部的阅读进度条。切换的成本只有一行代码。

