2025 年,Chrome 117+、Firefox 129+、Safari 18+ 全面支持了三个 CSS 新特性——@starting-style、transition-behavior: allow-discrete 和 interpolate-size: allow-keywords。这三个特性组合起来,让 CSS 终于能独立实现完整的进出场动画:元素入场淡入、离场淡出、display: none 切换时的平滑过渡、以及 height: auto 的丝滑展开收起——全部不需要一行 JavaScript 动画代码。据统计,仅 @starting-style 一项就能让中小型项目减少 40% 以上的动画库依赖(GSAP、Framer Motion 等),CSS 打包体积平均减少 15-30KB。
本文不是对新特性的简单介绍,而是用真实场景的完整代码,逐一拆解每个特性的原理、用法、坑点和最佳实践,帮你在下一个项目中彻底用 CSS 拿下所有进出场动画需求。
🎬 一、为什么 CSS 一直以来做不好进出场动画
1.1 核心矛盾:CSS 过渡需要「两个状态」
CSS transition 的工作原理是监听属性从旧值到新值的变化,然后在两者之间插值。这带来了两个根本性限制:
❌ 元素首次渲染时没有「旧值」,所以无法触发入场动画:
/* ❌ 错误写法:新创建的元素不会有过渡效果 */
.box {
opacity: 1;
transition: opacity 0.3s;
}
❌ display: none 是离散值,无法在 none 和 block 之间插值:
/* ❌ 错误写法:display 切换没有过渡 */
.box.hidden {
display: none;
opacity: 0;
}
这就是为什么过去十年里,开发者不得不依赖 JavaScript 动画库(GSAP、anime.js、Framer Motion)或手写 classList + setTimeout 来实现进出场效果。现在,三个 CSS 新特性彻底解决了这些问题。
1.2 三个特性的分工
| 特性 | 解决的问题 | 浏览器支持 |
|---|---|---|
@starting-style |
入场动画:首次渲染时定义初始状态 | Chrome 117+, Firefox 129+, Safari 18+ |
transition-behavior: allow-discrete |
离散过渡:让 display、overlay 等离散属性也能参与过渡 |
Chrome 117+, Firefox 129+, Safari 18+ |
interpolate-size: allow-keywords |
关键字尺寸过渡:让 height: auto、width: max-content 等能平滑过渡 |
Chrome 129+, Firefox 134+, Safari 18.2+ |
⚠️ 警告:
interpolate-size的浏览器支持略晚于前两个特性。如果你的项目需要支持 Chrome 128 以下,需要配合 JS polyfill 或使用calc-size()函数作为降级方案。
🚀 二、@starting-style:纯 CSS 入场动画
2.1 基本原理
@starting-style 允许你在元素首次渲染时指定一组初始样式。浏览器会在渲染第一帧时应用这些初始值,然后在过渡到最终值,从而产生入场动画效果。
/* ✅ 正确写法:用 @starting-style 实现入场淡入 */
.toast {
opacity: 1;
transform: translateY(0);
transition: opacity 0.4s ease, transform 0.4s ease;
}
@starting-style {
.toast {
opacity: 0;
transform: translateY(20px);
}
}
当 .toast 元素被插入 DOM 时,浏览器会:
- 第一帧渲染
opacity: 0; transform: translateY(20px)(来自@starting-style) - 过渡到
opacity: 1; transform: translateY(0)(来自元素本身的样式)
整个过程完全由 CSS 驱动,不需要 requestAnimationFrame、不需要 setTimeout、不需要 nextTick。
2.2 实战:Toast 通知组件
<!-- toast.html -->
<div class="toast-container">
<div class="toast toast--success">✅ 操作成功</div>
</div>
<style>
.toast-container {
position: fixed;
top: 20px;
right: 20px;
display: flex;
flex-direction: column;
gap: 8px;
}
.toast {
padding: 12px 20px;
border-radius: 8px;
background: #10b981;
color: white;
font-size: 14px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
/* 最终状态 */
opacity: 1;
transform: translateY(0) scale(1);
transition:
opacity 0.35s ease,
transform 0.35s ease,
display 0.35s allow-discrete;
}
/* 入场初始状态 */
@starting-style {
.toast {
opacity: 0;
transform: translateY(-12px) scale(0.95);
}
}
</style>
💡 提示:
@starting-style规则可以写在全局样式中,也可以嵌套在元素的选择器内部(CSS Nesting 语法)。推荐写在全局,方便统一管理入场动画。
2.3 与 Dialog、Popover 的配合
@starting-style 特别适合与原生 <dialog> 和 popover 属性配合,因为这些元素是通过浏览器 API 控制显隐的,你无法通过 JavaScript 在「插入 DOM」的瞬间手动添加类名:
/* dialog 入场动画 */
dialog {
opacity: 1;
transform: scale(1);
transition: opacity 0.25s ease, transform 0.25s ease;
}
@starting-style {
dialog[open] {
opacity: 0;
transform: scale(0.9);
}
}
/* popover 入场动画 */
[popover]:popover-open {
opacity: 1;
transform: translateY(0);
transition: opacity 0.2s, transform 0.2s;
}
@starting-style {
[popover]:popover-open {
opacity: 0;
transform: translateY(-8px);
}
}
⚠️ 警告:
@starting-style仅在元素首次插入 DOM 或从display: none变为可见时触发。如果你需要反复触发入场动画(比如 Toast 自动消失后再出现),需要在元素移除后重新创建,而不是通过 class 切换显隐。
🔀 三、transition-behavior: allow-discrete:让 display 也能过渡
3.1 离散属性的困境
CSS 属性分为两类:
- 可插值属性(如
opacity、transform、color):浏览器可以在旧值和新值之间平滑过渡 - 离散属性(如
display、visibility、overlay):只有两个状态,无法插值
过去,display: none 到 display: block 的切换是瞬间完成的,动画根本来不及播放就被「隐藏」了。transition-behavior: allow-discrete 改变了这个规则——它告诉浏览器:即使属性是离散的,也要在过渡期间保持旧状态,直到动画完成。
/* ✅ 正确写法:display 参与过渡 */
.panel {
display: block;
opacity: 1;
transition:
opacity 0.3s ease,
display 0.3s allow-discrete; /* 关键! */
}
.panel.hidden {
display: none;
opacity: 0;
}
当 .hidden 被添加时:
opacity从 1 过渡到 0(可插值)display在整个 0.3 秒内保持为block(allow-discrete的作用)- 0.3 秒后,
display瞬间切换为none
这意味着元素在淡出动画完成之前不会被隐藏,不再需要手写 setTimeout 来延迟 display: none。
3.2 实战:可折叠面板(Accordion)
<!-- accordion.html -->
<details class="accordion" open>
<summary class="accordion__trigger">点击展开/收起</summary>
<div class="accordion__content">
<p>这是面板内容。收起时会先播放淡出动画,再设置 display: none。</p>
<p>整个过程纯 CSS,零 JavaScript。</p>
</div>
</details>
<style>
.accordion__content {
overflow: hidden;
display: block;
opacity: 1;
max-height: 500px;
transition:
opacity 0.3s ease,
max-height 0.3s ease,
display 0.3s allow-discrete;
}
/* 兼容:details[open] 是展开状态 */
details:not([open]) .accordion__content {
display: none;
opacity: 0;
max-height: 0;
}
/* 入场动画 */
@starting-style {
details[open] .accordion__content {
opacity: 0;
max-height: 0;
}
}
</style>
3.3 overlay 属性与顶层元素
transition-behavior: allow-discrete 同样适用于 overlay 属性,这对 <dialog> 的「返回顶层」行为至关重要:
dialog {
transition:
opacity 0.3s ease,
display 0.3s allow-discrete,
overlay 0.3s allow-discrete;
}
dialog:not([open]) {
opacity: 0;
display: none;
}
overlay: auto 确保 dialog 在关闭动画期间仍然保持在顶层(top layer),不会被其他内容遮挡。没有 overlay 过渡,dialog 可能在淡出动画开始前就退出了顶层,导致视觉闪烁。
💡 **提示:**在所有涉及
display过渡的场景中,建议同时加上overlay 0.3s allow-discrete。这是很多开发者忽略的细节,但在 dialog、fullscreen 等顶层元素中会导致动画闪烁。
📏 四、interpolate-size: allow-keywords:终于能动画 height: auto
4.1 前端最古老的痛点
「如何用 CSS 过渡 height: auto?」——这个问题在 Stack Overflow 上有超过 10 万个赞,前端社区为此写了无数个 hack:max-height: 9999px、scrollHeight + requestAnimationFrame、ResizeObserver 等。interpolate-size: allow-keywords 从根源上解决了这个问题。
/* ✅ 一行代码搞定 height: auto 动画 */
html {
interpolate-size: allow-keywords;
}
.expandable {
height: auto;
overflow: hidden;
transition: height 0.35s ease;
}
.expandable.collapsed {
height: 0;
}
是的,就这么简单。interpolate-size: allow-keywords 告诉浏览器:当过渡涉及到关键字尺寸(auto、min-content、max-content、fit-content)时,先计算出实际像素值,再进行插值过渡。
4.2 实战:手风琴面板(纯 CSS,完美高度过渡)
<!-- accordion-perfect.html -->
<div class="accordion-perfect">
<button class="accordion-perfect__toggle"
onclick="this.parentElement.classList.toggle('open')">
📦 点击展开详情
</button>
<div class="accordion-perfect__body">
<div class="accordion-perfect__inner">
<h3>商品详情</h3>
<p>这是一段动态长度的内容。无论内容有多少行,展开和收起的动画都是丝滑的,因为浏览器会自动计算 height: auto 的实际像素值。</p>
<ul>
<li>材质:100% 纯棉</li>
<li>尺码:S / M / L / XL</li>
<li>产地:中国</li>
</ul>
</div>
</div>
</div>
<style>
html {
interpolate-size: allow-keywords;
}
.accordion-perfect__body {
height: 0;
overflow: hidden;
transition: height 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.accordion-perfect.open .accordion-perfect__body {
height: auto; /* 浏览器会自动计算实际高度并平滑过渡! */
}
/* 入场动画 */
@starting-style {
.accordion-perfect.open .accordion-perfect__body {
height: 0;
}
}
.accordion-perfect__inner {
padding: 16px;
}
</style>
4.3 calc-size() 函数:更灵活的尺寸过渡
除了 interpolate-size: allow-keywords 全局开关,你还可以使用 calc-size() 函数对关键字尺寸做运算后再过渡:
/* 高度过渡到 auto 的一半(比如只展开一半内容) */
.partial-expand {
height: 0;
overflow: hidden;
transition: height 0.3s ease;
}
.partial-expand.open {
height: calc-size(auto, size * 0.5);
}
calc-size() 的第一个参数是关键字尺寸(auto、min-content、max-content、fit-content),第二个参数是对计算出的像素值的表达式。这在需要「部分展开」或「按比例缩放」时非常有用。
⚠️ 警告:
calc-size()的浏览器支持比interpolate-size更晚(Chrome 129+)。如果需要兼容旧浏览器,优先使用interpolate-size: allow-keywords全局方案。
🏗️ 五、三个特性的组合实战
5.1 完整的 Modal 弹窗(零 JavaScript 动画代码)
下面是一个结合了三个特性的完整 Modal 弹窗,入场淡入、离场淡出、overlay 顶层管理、背景遮罩——全部由 CSS 完成:
<!-- modal.html -->
<button onclick="document.querySelector('#myModal').showModal()">
打开弹窗
</button>
<dialog id="myModal">
<div class="modal-backdrop"></div>
<div class="modal-panel">
<h2>确认操作</h2>
<p>你确定要删除这条记录吗?此操作不可撤销。</p>
<div class="modal-actions">
<button onclick="this.closest('dialog').close()">取消</button>
<button class="btn-danger" onclick="this.closest('dialog').close()">
确认删除
</button>
</div>
</div>
</dialog>
<style>
html {
interpolate-size: allow-keywords;
}
dialog {
border: none;
padding: 0;
background: transparent;
/* 过渡声明 */
transition:
opacity 0.3s ease,
display 0.3s allow-discrete,
overlay 0.3s allow-discrete;
}
dialog:not([open]) {
opacity: 0;
}
/* 入场动画 */
@starting-style {
dialog[open] {
opacity: 0;
}
}
/* 背景遮罩 */
dialog::backdrop {
background: rgba(0, 0, 0, 0.5);
opacity: 1;
transition:
opacity 0.3s ease,
display 0.3s allow-discrete;
}
dialog:not([open])::backdrop {
opacity: 0;
}
@starting-style {
dialog[open]::backdrop {
opacity: 0;
}
}
/* 弹窗面板 */
.modal-panel {
background: white;
border-radius: 12px;
padding: 24px;
max-width: 480px;
width: 90vw;
transform: scale(1) translateY(0);
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@starting-style {
dialog[open] .modal-panel {
transform: scale(0.9) translateY(10px);
}
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
}
.btn-danger {
background: #ef4444;
color: white;
border: none;
padding: 8px 16px;
border-radius: 6px;
}
</style>
这个弹窗实现了:
- ✅ 打开时面板缩放 + 淡入,背景遮罩渐显
- ✅ 关闭时反向动画,且动画完成后才退出顶层
- ✅ 零 JavaScript 动画代码,只用原生
showModal()/close() - ✅ 背景遮罩使用
::backdrop伪元素,同样支持过渡
5.2 通知列表(动态插入 + 自动移除)
// notify.js — 唯一的 JS:负责插入和移除 DOM 元素
function showToast(message, type = 'success') {
const container = document.querySelector('.toast-container');
const toast = document.createElement('div');
toast.className = `toast toast--${type}`;
toast.textContent = message;
container.appendChild(toast);
// 3 秒后移除,CSS 负责播放淡出动画
setTimeout(() => {
toast.classList.add('toast--exit');
toast.addEventListener('transitionend', () => toast.remove(), { once: true });
}, 3000);
}
/* toast.css — CSS 驱动所有动画 */
.toast-container {
position: fixed;
top: 16px;
right: 16px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 1000;
}
.toast {
padding: 12px 20px;
border-radius: 8px;
color: white;
font-size: 14px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
cursor: pointer;
/* 入场动画 */
opacity: 1;
transform: translateX(0);
transition:
opacity 0.3s ease,
transform 0.3s ease;
}
@starting-style {
.toast {
opacity: 0;
transform: translateX(40px);
}
}
/* 离场动画 */
.toast--exit {
opacity: 0;
transform: translateX(40px);
}
.toast--success { background: #10b981; }
.toast--error { background: #ef4444; }
.toast--info { background: #3b82f6; }
📌 记住:
@starting-style只在元素首次渲染时触发一次。对于 Toast 这种需要反复出现的组件,每次都需要createElement+appendChild,而不是通过 class 切换来显隐。这是使用@starting-style时最常见的设计误区。
⚡ 六、性能对比与选型建议
6.1 CSS 原生动画 vs JavaScript 动画库
| 维度 | CSS 原生动画 | GSAP | Framer Motion | anime.js |
|---|---|---|---|---|
| 包体积 | 0 KB | ~30 KB | ~45 KB | ~17 KB |
| 首次加载影响 | 无 | 中 | 高 | 低 |
| 入场动画 | ✅ @starting-style |
✅ | ✅ | ✅ |
| 离场动画 | ✅ transition-behavior |
✅ | ✅ | ⚠️ 需要回调 |
height: auto |
✅ interpolate-size |
✅ | ✅ | ❌ |
| 复杂时间线 | ❌ | ✅ | ⚠️ | ✅ |
| 弹性/物理动画 | ❌ | ✅ | ✅ | ❌ |
| 交互驱动动画 | ❌ | ✅ | ✅ | ❌ |
| 开发体验 | 声明式 | 命令式 | 声明式 | 命令式 |
| SSR 兼容 | ✅ 完美 | ⚠️ 需处理 | ⚠️ 需处理 | ⚠️ 需处理 |
⚡ **关键结论:**如果你的项目只需要进出场动画、折叠展开、Modal 弹窗等标准 UI 动画,CSS 原生方案是最佳选择——零依赖、零体积、完美 SSR。只有当你需要复杂时间线、物理弹性动画或交互拖拽驱动的动画时,才需要引入 JavaScript 动画库。
6.2 需要 JavaScript 动画库的场景
以下场景仍然推荐使用 GSAP 或 Framer Motion:
- 🎯 多元素序列动画(stagger),如列表项依次入场
- 🎯 拖拽交互驱动的动画(如滑动删除)
- 🎯 物理弹性动画(spring physics)
- 🎯 复杂 SVG 路径动画
- 🎯 需要在动画过程中精确控制中间状态(scroll-linked 复杂场景)
💡 七、避坑指南与最佳实践
7.1 常见坑点
❌ 坑点 1:忘记 display 的过渡顺序
/* ❌ 错误:display 在 opacity 之前声明,可能不会生效 */
transition: display 0.3s allow-discrete, opacity 0.3s ease;
/* ✅ 正确:可插值属性在前,离散属性在后 */
transition: opacity 0.3s ease, display 0.3s allow-discrete;
❌ 坑点 2:用 class 切换触发动画而非 DOM 插入
/* ❌ @starting-style 只在首次渲染时生效,class 切换不会触发 */
.toast.show { opacity: 1; }
// ❌ 不会触发动画
toast.classList.add('show');
// ✅ 会触发动画
container.appendChild(toast);
❌ 坑点 3:忘记设置 overflow: hidden
/* ❌ height 过渡时内容会溢出 */
.panel {
height: auto;
transition: height 0.3s;
}
/* ✅ 加上 overflow: hidden */
.panel {
height: auto;
overflow: hidden;
transition: height 0.3s;
}
7.2 渐进增强策略
对于需要兼容旧浏览器的项目,使用 @supports 进行渐进增强:
/* 基础方案:无动画,保证功能可用 */
.dialog {
display: none;
}
.dialog[open] {
display: block;
}
/* 增强方案:有动画 */
@supports (transition-behavior: allow-discrete) {
dialog {
transition:
opacity 0.3s ease,
display 0.3s allow-discrete,
overlay 0.3s allow-discrete;
opacity: 1;
}
dialog:not([open]) {
opacity: 0;
}
@starting-style {
dialog[open] {
opacity: 0;
}
}
}
7.3 最佳实践清单
- ✅ 在全局
html或:root上设置interpolate-size: allow-keywords,统一开启关键字尺寸过渡 - ✅ 过渡声明的顺序:可插值属性 →
display→overlay - ✅ 使用
transitionend事件监听动画完成后再移除 DOM - ✅ 给
height过渡的容器加overflow: hidden - ✅ 使用
@supports进行渐进增强,确保旧浏览器的功能可用性 - ❌ 不要用
@starting-style做需要反复触发的动画 - ❌ 不要忘记
overlay过渡——否则顶层元素会出现闪烁 - ⚠️ 在移动端测试性能,确保动画帧率稳定在 60fps
📝 总结
@starting-style、transition-behavior: allow-discrete 和 interpolate-size: allow-keywords 三个特性的组合,标志着 CSS 动画能力的一次质的飞跃。对于 80% 的 UI 动画需求(进出场、折叠展开、弹窗、通知),纯 CSS 方案已经完全够用,而且在包体积、SSR 兼容性和开发体验上全面优于 JavaScript 动画库。
作为开发者,建议你在新项目中优先使用 CSS 原生动画,只在确实需要复杂时间线、物理弹性或交互驱动动画时才引入 GSAP / Framer Motion。同时,使用 @supports 检测确保旧浏览器的渐进降级——功能永远优先于动效。
相关工具推荐:
- 📐 Easing Functions Cheat Sheet — 缓动函数可视化参考
- 🎨 Animista — CSS 动画在线生成器
- 🔧 Chrome DevTools Animations Panel — 动画调试利器
- 📦 jsjson.com 在线工具 — JSON 格式化、代码转换等开发者工具集