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