写过前端的都踩过这个坑——弹窗入场动画每次都要靠 setTimeout(fn,0) 硬撑,@starting-style 今天把它彻底原生化了

写过前端的都踩过这个坑——弹窗入场动画每次都要靠 setTimeout(fn,0) 硬撑,@starting-style 今天把它彻底原生化了


做前端的,谁没写过这几行代码:往 DOM 里塞一个 toast,塞完之后等一帧,再给它加上可见的 class。用的要么是 setTimeout(..., 16),要么是连写两个 requestAnimationFrame,要么是 el.getBoundingClientRect() 强制触发一次重排。

为什么?因为 CSS transition 需要两个状态才能做插值。元素刚插入 DOM 时没有「之前的状态」,浏览器找不到起始帧,过渡动画直接被跳过。这不是浏览器的 bug,是 CSS 过渡本身的设计——它只能做状态 A 到状态 B 的变化,不能凭空创造第一个状态。

所以这么多年,弹窗、Toast、下拉菜单的入场动画,只能靠 JS 配合渲染管道的时间差来实现。这是一层不该存在的耦合。

@starting-style 是什么

Chrome 117、Safari 17.5、Firefox 129 陆续稳定之后,多了一个 @starting-style at-rule,它的作用很简单:声明一个元素在「第一帧渲染」时的样式——也就是给浏览器提供一个「起始状态」。

.toast {
  opacity: 1;
  transform: translateY(0);
  transition: opacity 0.3s ease, transform 0.3s ease;
}

@starting-style {
  .toast {
    opacity: 0;
    transform: translateY(-12px);
  }
}

这四行就完成了以前需要两段 JS 才能做到的事:往 DOM 里塞 .toast,浏览器自动从 @starting-style 里读 opacity: 0 作为起始帧,然后过渡到 opacity: 1

从入场动画到完整的显示/隐藏循环

只解决入场动画还不够,实际项目里最常见的需求是「显示→隐藏」的完整循环——弹窗点开要上来,关掉要收回去。

这里需要另一个配套语法:transition-behavior: allow-discrete。CSS 里的 displayvisibility 属于「离散属性」,正常情况下 transition 直接跳过它们。allow-discrete 告诉浏览器:我要把 display 也当成可过渡的属性来对待。

.modal {
  display: none;
  opacity: 0;
  transform: translateY(20px);
  transition:
    opacity 0.25s ease,
    transform 0.25s ease,
    display 0.25s allow-discrete,
    overlay 0.25s allow-discrete;
}

.modal.is-open {
  display: block;
  opacity: 1;
  transform: translateY(0);
}

@starting-style {
  .modal.is-open {
    opacity: 0;
    transform: translateY(20px);
  }
}

关键点在这里:.is-open 里的 display: block 写在 opacity: 1 之前,因为 allow-discrete 会让 display 在过渡期间保持 block,这样动画才能完整跑完再切换回 none

还有一个 overlay 属性——它是给 <dialog>[popover] 这类「顶层元素」准备的。弹窗和对话框默认在浏览器的 top-layer 里渲染,overlay 属性控制过渡期间是否维持在 top-layer。不写的话,关掉弹窗的瞬间元素就离开顶层了,过渡直接消失。

配合 Popover API:零 JS 的完整方案

这个组合真正让人惊讶的地方在于,配合 HTML 的 [popover] 属性,可以做出完全不需要 JS 的可动画浮层:

<button popovertarget="my-toast">显示提示</button>
<div popover id="my-toast">保存成功</div>
[popover] {
  opacity: 0;
  transform: scale(0.95) translateY(10px);
  transition:
    opacity 0.2s ease,
    transform 0.2s ease,
    display 0.2s allow-discrete,
    overlay 0.2s allow-discrete;
}

[popover]:popover-open {
  opacity: 1;
  transform: scale(1) translateY(0);
}

@starting-style {
  [popover]:popover-open {
    opacity: 0;
    transform: scale(0.95) translateY(10px);
  }
}

[popover]:not(:popover-open) {
  opacity: 0;
  transform: scale(0.95) translateY(10px);
}

点击按钮由 [popover] 原生处理,popover-open 状态由浏览器自动切换,CSS 负责所有动画。完全不需要 JS 监听、class 切换、state 管理。

浏览器支持

Chrome 117、Edge 117、Firefox 129、Safari 17.5 起全面支持,Baseline 2024,全球约 91% 覆盖。如果你的项目还需要兼容更老的浏览器,渐进增强写法很简单——不支持 @starting-style 的浏览器会直接忽略它,元素以最终状态出现,没有动画而已。

@supports (transition-behavior: allow-discrete) {
  .modal {
    transition: opacity 0.25s ease, display 0.25s allow-discrete;
  }
  @starting-style {
    .modal { opacity: 0; }
  }
}

@starting-style vs CSS Keyframes:有什么区别

CSS 大 V Josh W. Comeau 专门写了一篇《The Big Gotcha With @starting-style》,提出了一个值得思考的问题:绝大多数 @starting-style 能做到的效果,用 CSS Keyframes 其实也能做,而且有时候更简单。

他说得有道理。但有一个关键区别是 Keyframes 做不到的:中断处理

想象这个场景:用户快速点开弹窗又立刻关掉,动画还没播完。如果用的是 Keyframes,元素会「闪」回起点(动画被中断然后重置),视觉上会出现一次抖动。而 @starting-style 配合 transition 的方案,会从「中断时的状态」继续过渡到关闭状态——没有闪烁,更流畅。

所以对于需要频繁打开关闭的组件(Toast、下拉菜单、通知横幅),@starting-style 其实是更稳妥的选择。

下一步:现在就可以用

不需要任何 polyfill,不需要任何构建配置。如果你在做一个新项目,直接写进去就行。如果你在维护一个老项目,可以在 toast、modal 这类组件里逐步替换掉 requestAnimationFrame 的那两行 JS。

这大概就是 CSS 最近的节奏——原本需要 JS 才能做到的事,一点一点被浏览器原生接走。这个坑踩了十几年,今天算是彻底填上了。

评论区

0 条评论

登录后可评论。