在“顶层”呈现的元素(例如 <dialog>、具有 popover 属性的元素或提示)历来很难添加动画效果,因为它们会在 display: none 和可见状态之间切换。现代 CSS 提供了 @starting-style、transition-behavior: allow-discrete 和 overlay 属性,可为这些元素实现流畅的进入和退出过渡。请注意,以下示例中使用了原生 CSS 嵌套。
实现
1. 启用离散过渡
如需为 display 属性添加动画效果,您必须设置 transition-behavior: allow-discrete。这样,元素在退出过渡期间就能保持可见。如果使用过渡简写,请务必将 transition-behavior: allow-discrete 放在其后,以防止简写将其否定。
2. overlay 属性
当元素移入或移出顶层时,必须转换 overlay 属性。这样可确保元素在动画播放期间始终位于顶层,从而防止其被其他元素或视口过早剪裁。
3. 使用 @starting-style 的进入动画
使用 @starting-style at 规则定义元素在首次渲染或其 display 从 none 更改时应从哪些样式开始过渡。
4. 为背景幕添加动画效果
通过对 ::backdrop 伪元素自身的属性应用过渡效果,也可以实现类似的动画效果。
示例
/* 1. Define the visible (open) state */
dialog[open],
[popover]:popover-open {
opacity: 1;
transform: scale(1);
/* 2. Define the starting state for entry (must come after open state) */
@starting-style {
opacity: 0;
transform: scale(0.9);
}
}
/* 3. Define the base (closed/exit) state and transitions */
dialog,
[popover] {
opacity: 0;
transform: scale(0.9);
/* MANDATORY: transition display and overlay for top-layer elements */
transition-property: opacity, transform, display, overlay;
transition-duration: 0.3s;
transition-timing-function: ease-out;
/* Applies to discrete properties like display and overlay */
transition-behavior: allow-discrete; /* Note: be sure to write this after the shorthand */
}
/* 4. Animate the backdrop */
dialog::backdrop,
[popover]::backdrop {
background-color: rgba(0, 0, 0, 0);
transition-property: background-color, display, overlay;
transition-duration: 0.3s;
transition-timing-function: ease-out;
transition-behavior: allow-discrete;
}
dialog[open]::backdrop,
[popover]:popover-open::backdrop {
background-color: rgba(0, 0, 0, 0.5);
@starting-style {
background-color: rgba(0, 0, 0, 0);
}
}
/* 5. Respect user preference for reduced motion */
@media (prefers-reduced-motion: reduce) {
dialog,
[popover] {
/* Disable movement and shorten duration for a simple fade */
transform: none;
transition-duration: 0.1s;
}
@starting-style {
dialog[open],
[popover]:popover-open {
transform: none;
}
}
}
约束条件和无障碍功能
- 强制性:对于移入或移出顶层的任何元素,请在
transition列表中添加overlay。 - 强制性:使用
allow-discrete进行display属性转换。 - 强制性:通过简化过渡效果(例如,移除转换和缩短时长),使用
prefers-reduced-motion遵循用户对减少动画效果的偏好。 - 正确做法:将
@starting-style代码块放在“open”状态选择器内部或之后,以确保正确级联。 - 请勿:将
@starting-style用于退出动画;退出动画由向基本(关闭)状态的过渡定义。
后备策略
transition-behavior 的回退和浏览器支持
Firefox 129+ 会解析 transition-behavior: allow-discrete(CSS.supports('transition-behavior', 'allow-discrete') 会返回 true),但实际上不会转换 display 属性(Firefox bug 1882408),从而导致元素在退出时立即消失。
为了可靠地检测离散 display 过渡支持,请探测临时元素的计算 display 在过渡到 none 时是否保持可见,而不是仅依赖于 CSS.supports('transition-behavior', 'allow-discrete'):
let supportsDisplayTransition;
function canTransitionDisplay() {
if (supportsDisplayTransition !== undefined) return supportsDisplayTransition;
if (!window.CSS?.supports?.('transition-behavior', 'allow-discrete') || !document.body) {
return false;
}
const probe = document.createElement('div');
// The shorthand is intentional here: browsers that don't parse allow-discrete
// drop the whole declaration, so display: none applies instantly and the probe
// correctly returns false. !important guards against global reduced-motion
// resets like `* { transition: none !important }`.
probe.style.cssText = 'transition: display 1s allow-discrete !important; display: block;';
document.body.appendChild(probe);
getComputedStyle(probe).display;
probe.style.display = 'none';
supportsDisplayTransition = getComputedStyle(probe).display === 'block';
probe.remove();
return supportsDisplayTransition;
}
顶层退出事件后备
在所有支持 @starting-style 的浏览器中,入场动画都可以在纯 CSS 中运行,无需 .is-opening 类,因为入场过渡不依赖于 overlay 或离散 display 过渡。
退出动画需要同时支持 overlay 和离散 display 过渡。如果其中任何一个不受支持(例如在 Firefox 和 Safari 中),请将打开状态选择器封装在 :is() 中,并附加 :where(:not([data-closing]))(嵌套 &::backdrop 和 @starting-style),以便在元素仍位于顶层时,设置 data-closing 会触发退出过渡,然后等待 getAnimations() 稳定后再调用 .close() 或 .hidePopover():
:is(dialog[open], [popover]:popover-open):where(:not([data-closing])) {
opacity: 1;
transform: scale(1);
@starting-style {
opacity: 0;
transform: scale(0.9);
}
&::backdrop {
background-color: rgb(0 0 0 / 0.5);
@starting-style {
background-color: transparent;
}
}
}
// Evaluate lazily: canTransitionDisplay() needs document.body, so a top-level
// const would be permanently false if this script runs in <head>.
function supportsTopLayerExit() {
return window.CSS?.supports?.('overlay', 'auto') && canTransitionDisplay();
}
async function closeTopLayer(element) {
if (!supportsTopLayerExit()) {
element.setAttribute('data-closing', '');
const animations = element.getAnimations({ subtree: true });
if (animations.length > 0) {
await Promise.race([
Promise.allSettled(animations.map((a) => a.finished)),
new Promise((r) => setTimeout(r, 2000)),
]);
}
if (!element.hasAttribute('data-closing')) return;
element.removeAttribute('data-closing');
}
element.close();
// Or for popover:
// element.hidePopover();
}
// Route native close requests (Esc, closedby light dismiss) through the same
// helper so they animate too, instead of closing instantly.
dialog.addEventListener('cancel', (event) => {
event.preventDefault();
closeTopLayer(dialog);
});
无法拦截弹出式窗口轻度关闭和 popovertarget 切换(beforetoggle 仅在打开时可取消),因此在需要后备方案的浏览器中,这些退出操作会立即执行。如果退出动画很重要,请提供一个显式关闭控件来调用 closeTopLayer()。
Popover 的回退和浏览器支持
Popover API 主要是一种渐进增强技术,但其核心行为(顶层提升、轻触关闭和 popovertarget 调用)没有仅限 CSS 的等效项。旧版浏览器需要 polyfill,如果您不想提供 polyfill,则需要手动后备方案。
Polyfill:如需在旧版浏览器中支持 popover 属性,请有条件地加载 @oddbird/popover-polyfill。强制性要求:通过检查 HTMLElement.prototype 上的 popover 属性来检测功能,并且仅在缺少原生支持时加载 polyfill,请勿无条件加载。
使用打包工具或导入映射:
// MANDATORY: Feature detect 'popover' on HTMLElement.prototype.
if (!("popover" in HTMLElement.prototype)) {
import("@oddbird/popover-polyfill");
}
如果没有打包器,请从 <script type="module"> 内的 CDN 导入:
<script type="module">
if (!("popover" in HTMLElement.prototype)) {
import("https://unpkg.com/@oddbird/popover-polyfill@latest/dist/popover.min.js");
}
</script>
样式注意事项:polyfill 无法定义真正的 :popover-open 伪类,因此它会改为应用 .\:popover-open 类。强制性:使用 :is() 或 :where() 将两者结合,否则缺少 :popover-open 的浏览器会舍弃整个规则:
[popover]:is(:popover-open, .\:popover-open) {
display: block;
}
或者,对于没有 polyfill 的旧版后备方案,请使用 position: fixed 并通过 getBoundingClientRect() 手动计算坐标,或依赖 inset: auto 的默认定位(如果该用例可以接受)。