要素の開始と終了をアニメーション化する

以前は、CSS 遷移では、要素が最初に DOM に追加されたときや、display プロパティが none から変更されたときに、要素をアニメーション化できませんでした。@starting-style アットルールと transition-behavior: allow-discrete を使用すると、スムーズな開始アニメーションと終了アニメーションを宣言的に作成できます。

実装

1. display: none の切り替えをアニメーション化する

属性(display: none を含む hidden など)を使用して要素の表示を切り替えるときに要素をアニメーション化するには:

  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 以降では、display プロパティを実際に遷移させることなく transition-behavior: allow-discrete を解析します(CSS.supports('transition-behavior', 'allow-discrete') は true を返します)(Firefox のバグ 1882408)。これにより、要素が終了時にすぐに消えてしまいます。

個別の display 遷移のサポートを確実に検出するには、CSS.supports('transition-behavior', 'allow-discrete') のみに依存するのではなく、none に遷移したときに一時要素の計算された display が表示されたままになるかどうかを調べます。

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();
}