为元素进入和退出添加动画效果

过去,当元素首次添加到 DOM 或其 display 属性从 none 更改时,CSS 过渡无法为元素添加动画效果。@starting-style at 规则和 transition-behavior: allow-discrete 提供了一种声明式方法,可用于创建流畅的进入和退出动画。

实现

1. 为 display: none 切换开关添加动画效果

如需在通过属性(例如,hidden 与 display: none)切换元素的可见性时为元素添加动画效果,请执行以下操作:

  1. 定义可见状态:在基类上设置最终属性值(例如 opacity: 1)。
  2. 定义进入起始状态:使用 @starting-style 指定元素变为可见时从哪些值开始过渡。
  3. 启用离散转换:在 transition 属性中添加 display,并使用 transition-behavior: allow-discrete。
  4. 定义退出状态:在 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();
}