过去,当元素首次添加到 DOM 或其 display 属性从 none 更改时,CSS 过渡无法为元素添加动画效果。@starting-style at 规则和 transition-behavior: allow-discrete 提供了一种声明式方法,可用于创建流畅的进入和退出动画。
实现
1. 为 display: none 切换开关添加动画效果
如需在通过属性(例如,hidden 与 display: none)切换元素的可见性时为元素添加动画效果,请执行以下操作:
- 定义可见状态:在基类上设置最终属性值(例如
opacity: 1)。 - 定义进入起始状态:使用
@starting-style指定元素变为可见时从哪些值开始过渡。 - 启用离散转换:在
transition属性中添加display,并使用transition-behavior: allow-discrete。 - 定义退出状态:在
hidden属性中设置目标值。
.card {
display: block;
opacity: 1;
translate: 0;
/* MANDATORY: Use transition-behavior: allow-discrete for display transition */
transition:
display 0.4s,
opacity 0.4s ease-out,
translate 0.4s ease-out;
transition-behavior: allow-discrete;
}
/* Entry animation: transition FROM these values when first rendered */
@starting-style {
.card {
opacity: 0;
translate: 0 -20px;
}
}
/* Exit animation: transition TO these values when hidden */
.card[hidden] {
display: none;
opacity: 0;
translate: 0 -20px;
}
/* Respect user preference for reduced motion */
@media (prefers-reduced-motion: reduce) {
.card {
/* Disable movement and shorten duration for a simple fade */
translate: none;
transition-duration: 0.1s;
}
@starting-style {
.card {
translate: none;
}
}
.card[hidden] {
translate: none;
}
}
2. 为 DOM 插入和移除添加动画效果
对于通过 appendChild() 添加或通过 remove() 移除的元素:
- 条目:使用
@starting-style,如上所示。浏览器会自动检测到样式从“无”到元素初始样式的变化,并触发从@starting-style值的过渡。 - 移除:由于
element.remove()是瞬时发生的,不会自行触发 CSS 过渡,因此您必须先触发退出过渡,并等待其完成,然后再从 DOM 中移除节点。
// 1. Trigger exit transition
element.hidden = true;
// 2. Wait for all active transitions/animations to finish,
// with a failsafe timeout in case an animation never ends (e.g. for looping animations)
const animations = element.getAnimations();
if (animations.length > 0) {
await Promise.race([
// Promise.allSettled ensures we wait even if some animations fail
Promise.allSettled(animations.map(a => a.finished)),
new Promise(r => setTimeout(r, 2000))
]);
}
// 3. Finally remove the node from the DOM
element.remove();
约束条件和无障碍功能
- 强制性:在转换
display时使用transition-behavior: allow-discrete。否则,元素会在退出时瞬间消失。 - 请勿在
transition简写中使用allow-discrete,否则会导致旧版浏览器忽略整个transition声明。除非在某些用例中需要这样做,否则请使用单独的transition-behavior: allow-discrete声明。 - 强制要求:使用
@starting-style播放进入动画。除非提供此属性,否则浏览器会在元素的首次样式更新(初始渲染或display: none更改)时跳过过渡。 - 建议:如果为
<dialog>或popover等顶层元素添加动画效果,请在transition列表中添加overlay,以确保这些元素在退出动画期间保持在顶层。 - 建议:使用
prefers-reduced-motion媒体查询,尊重用户对减少动画效果的偏好。 - 请勿:依赖
@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;
}
当不支持离散 display 过渡时,退出后备
使用 @starting-style 的进入动画可在所有现代浏览器中运行,无需 JavaScript。如果不支持离散 display 过渡 (!canTransitionDisplay()),设置 hidden 会立即应用 display: none 并跳过退出动画(在隐藏时和 element.remove() 之前)。将视觉退出状态 ([data-closing]) 与 display: none ([hidden]) 分开,以便 opacity 和 translate 在隐藏或移除元素之前完成动画。将第 1 步中的 .card[hidden] 规则(包括 prefers-reduced-motion 替换)替换为:
.card:where([hidden], [data-closing]) {
opacity: 0;
translate: 0 -20px;
}
.card[hidden] {
display: none;
}
@media (prefers-reduced-motion: reduce) {
.card:where([hidden], [data-closing]) {
translate: none;
}
}
async function hideElement(el) {
if (canTransitionDisplay()) {
el.hidden = true;
} else {
el.setAttribute('data-closing', '');
}
const animations = el.getAnimations();
if (animations.length > 0) {
await Promise.race([
Promise.allSettled(animations.map((a) => a.finished)),
new Promise((r) => setTimeout(r, 2000)),
]);
}
// Skipped if showElement() cancelled the close mid-animation.
if (el.hasAttribute('data-closing')) {
el.removeAttribute('data-closing');
el.hidden = true;
}
}
function showElement(el) {
el.removeAttribute('data-closing');
el.hidden = false;
}
async function removeElement(el) {
await hideElement(el);
el.remove();
}