「最上位レイヤ」にレンダリングされる要素(<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 でデフォルトの配置を使用します。