写过 CSS 入场动画的人都踩过这个坑——元素从 display: none 冒出来那一帧,要么没有过渡直接蹦出来,要么要写两段 JS 监听器才能让它「记住」自己从哪开始。今天 CSS 用两行 @starting-style 把这件事彻底原生化了,连 JS 都不用写。
写过 CSS 入场动画的人都踩过这个坑——元素从 display: none 冒出来那一帧,要么没有过渡直接蹦出来,要么要写两段 JS 监听器才能让它「记住」自己从哪开始。今天 CSS 用两行 @starting-style 把这件事彻底原生化了,连 JS 都不用写。
这个坑的本质是什么
CSS 过渡需要两个状态:起始值和终值。元素从无到有时,浏览器找不到「之前」的状态——它不知道你希望元素从 opacity: 0 还是 opacity: 0.3 开始,所以直接跳到终态,过渡效果自然就消失了。
以前的解法靠 JS:先加类让元素变成起始样式,浏览器渲染完下一帧,再加另一个类切到终态。这段「等一帧」的 JS 是所有 entry 动画抖动或失效的根源。
@starting-style 的解法
@starting-style 的作用就是告诉浏览器:「在这个元素第一次被渲染之前,它应该长这个样子」。浏览器会在元素进入视口的第一帧,自动把它套进 @starting-style 里定义的样式作为起始状态,然后立刻过渡到正常样式——整个过程零 JS。
举一个 dialog 的完整写法:
.dialog {
opacity: 1;
scale: 1;
transition: opacity 0.2s ease-out, scale 0.2s ease-out,
display 0.2s ease-out allow-discrete,
overlay 0.2s ease-out allow-discrete;
}
@starting-style {
.dialog {
opacity: 0;
scale: 0.95;
}
}
.dialog[hidden] {
opacity: 0;
scale: 0.95;
}
HTML 只需要一个 button 和一个 dialog:
<button popovertarget="my-dialog">打开</button>
<dialog id="my-dialog" popover>
<p>Hello, 零 JS 入场动画</p>
</dialog>
点击按钮,对话框从 opacity: 0 / scale: 0.95 过渡到 opacity: 1 / scale: 1,全程浏览器自己算时机,dialog 元素自带焦点管理和 Escape 关闭,都不需要 JS 管。
popover 配合更干净
如果不用 dialog 而是用普通 div + popover 属性,@starting-style 的效果也一样,只是省掉了浏览器内置的焦点管理——需要自己处理的话再用 JS:
.modal {
opacity: 1;
transform: translateY(0);
transition: opacity 0.3s ease-out, transform 0.3s ease-out,
display 0.3s ease-out allow-discrete;
}
@starting-style {
.modal {
opacity: 0;
transform: translateY(20px);
}
}
.modal:not([popover]) {
display: none;
}
用 popover 属性的话,浏览器会自动处理 show/hide,不需要手动管 display,@starting-style 会在元素第一次显示时自动触发。
display: none → visible 的动画
@starting-style 的触发条件是「元素第一次被渲染」,所以对于已经渲染过一次的元素,再次用 display: none 隐藏再显示时,@starting-style 不会再触发——元素会直接跳到正常样式。
如果每次显示都要有入场动画,需要用 popover 属性或者把元素从 DOM 里真正移除再插回去。popover 方案是 2026 年最推荐的做法。
浏览器支持
Chrome 117+、Edge 117+、Firefox 129+、Safari 17.5+ 全部支持,覆盖率已经超过 90%,可以直接在生产环境用。旧版浏览器会跳过动画直接显示元素,属于渐进增强,不影响功能。
下一步
下次要给一个卡片、弹窗、下拉菜单加入场动画时,试试把 JS 监听器删掉,加上 @starting-style 块。超过 90% 的用户会看到流畅的入场动画,剩下的用户看到的是即时出现——比 JS 抖动体验好得多,还省了代码。
评论区
登录后可评论。