為元素進入和離開建立動畫效果

過去,CSS 轉場效果無法在元素首次新增至 DOM 時,或 display 屬性從 none 變更時,為元素加上動畫效果。@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() 移除的元素:

  • Entry:如上所示,使用 @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 錯誤 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();
}