為頂層元素設定進場和退場動畫

在「頂層」算繪的元素 (例如 <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 錯誤 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:如要在舊版瀏覽器中支援 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 預設定位。