最上位レイヤとの間で要素をアニメーション化する

「最上位レイヤ」にレンダリングされる要素(<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-rule を使用して、要素が最初にレンダリングされたとき、または 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 を含めます。
  • 必須: display プロパティの遷移には allow-discrete を使用します。
  • 必須: prefers-reduced-motion を使用して、遷移を簡素化(変換の削除や期間の短縮など)することで、モーションを減らすというユーザー設定を尊重します。
  • 推奨: @starting-style ブロックを「open」状態セレクタの内部または後に配置して、適切なカスケードを確保します。
  • 禁止事項: 終了アニメーションに @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;
}

上位レイヤの exit の代替

エントリ アニメーションは、@starting-style をサポートするすべてのブラウザで純粋な CSS で動作します。エントリ遷移は overlay や個別の display 遷移に依存しないため、.is-opening クラスは必要ありません。

終了アニメーションには、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 のみの同等の機能がありません。古いブラウザでは、ポリフィルが必要です。ポリフィルを配布したくない場合は、手動でフォールバックする必要があります。

ポリフィル: 古いブラウザで popover 属性をサポートするには、条件付きで @oddbird/popover-polyfill を読み込みます。必須: HTMLElement.prototype の popover プロパティをチェックして機能を検出し、ネイティブ サポートがない場合にのみポリフィルを読み込みます。無条件に読み込まないでください。

バンドラまたはインポート マップを使用する場合:

// 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>

スタイリングに関する注意事項: ポリフィルは実際の :popover-open 疑似クラスを定義できないため、代わりに .\:popover-open クラスを適用します。必須: :is() または :where() を使用して 2 つを結合します。そうしないと、:popover-open がないブラウザではルール全体が破棄されます。

[popover]:is(:popover-open, .\:popover-open) {
  display: block;
}

または、ポリフィルを使用しないレガシー フォールバックの場合は、position: fixed を使用して getBoundingClientRect() で座標を手動で計算するか、ユースケースで許容される場合は inset: auto でデフォルトの配置を使用します。