Elements that render in the "top layer" (like <dialog>, elements with the popover attribute, or tooltips) have historically been difficult to animate because they toggle between display: none and a visible state. Modern CSS provides @starting-style, transition-behavior: allow-discrete, and the overlay property to enable smooth entry and exit transitions for these elements. Note that native CSS nesting is used in the examples below.
Implementation
1. Enable Discrete Transitions
To animate the display property, you must set transition-behavior: allow-discrete. This allows the element to remain visible during its exit transition. If using the transition shorthand, place transition-behavior: allow-discrete afterward so the shorthand does not reset it.
2. The overlay Property
When an element moves in or out of the top layer, it must transition the overlay property. This ensures the element stays in the top layer for the duration of the animation, preventing it from being clipped by other elements or the viewport prematurely.
3. Entry Animations with @starting-style
Use the @starting-style at-rule to define the styles an element should transition from when it is first rendered or its display changes from none.
4. Animating the Backdrop
The ::backdrop pseudo-element can be animated similarly by applying transitions to its own properties.
Example
/* 1. Define the visible (open) state */
dialog[open],
[popover]:popover-open {
opacity: 1;
/* 2. Define the starting state for entry (must come after open state) */
@starting-style {
opacity: 0;
}
/* Scale only for users who haven't asked for reduced motion (see 5) */
@media (prefers-reduced-motion: no-preference) {
transform: scale(1);
@starting-style {
transform: scale(0.9);
}
}
}
/* 3. Define the base (closed/exit) state and transitions */
dialog,
[popover] {
opacity: 0;
/* MANDATORY: transition display and overlay for top-layer elements */
transition-property: opacity, transform, display, overlay;
transition-duration: 0.3s;
transition-timing-function: ease-out;
/* Separate after transition so a shorthand doesn't reset it or fail in older browsers */
transition-behavior: allow-discrete;
@media (prefers-reduced-motion: no-preference) {
transform: scale(0.9);
}
}
/* 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;
/* Separate after transition so a shorthand doesn't reset it or fail in older browsers */
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: the scale above only applies
with no-preference, so this only shortens the fade */
@media (prefers-reduced-motion: reduce) {
dialog,
[popover] {
transition-duration: 0.1s;
}
}
Constraints & Accessibility
- MANDATORY: Include
overlayin yourtransitionlist for any element moving into or out of the top layer. - MANDATORY: Use
transition-behavior: allow-discretewhen transitioningdisplayandoverlay. - DO NOT: Use
allow-discretein thetransitionshorthand — it will make older browsers ignore the entiretransitiondeclaration. Except in use cases where that is desirable, use a separatetransition-behavior: allow-discretedeclaration. - MANDATORY: Respect user preferences for reduced motion by applying transforms only inside
@media (prefers-reduced-motion: no-preference)and shortening the transition duration inside@media (prefers-reduced-motion: reduce), so users who prefer reduced motion get a short fade. - DO: Place the
@starting-styleblock inside or after the "open" state selector to ensure proper cascading. - DO NOT: Use
@starting-stylefor exit animations; exit animations are defined by the transition to the base (closed) state.
Fallback strategies
Fallbacks & browser support for transition-behavior
Browser support for the css.properties.transition-behavior.transitionable_display capability: Limited availability. Supported by: Chrome 117 (Sep 2023), Edge 117 (Sep 2023), and Safari 18 (Sep 2024). Unsupported in: Firefox.
Firefox 129+ parses transition-behavior: allow-discrete (CSS.supports('transition-behavior', 'allow-discrete') returns true) without actually transitioning the display property (Firefox bug 1882408), causing elements to disappear immediately on exit.
To reliably detect discrete display transition support, probe whether a temporary element's computed display remains visible when transitioned to none rather than relying solely on 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;
}
Top-layer exit fallback
Entry animations work in pure CSS across all browsers that support @starting-style—no .is-opening class is needed because entry transitions do not depend on overlay or discrete display transitions.
Exit animations require both overlay and discrete display transition support. When either is unsupported (such as in Firefox and Safari), wrap the open-state selectors in :is() and append :where(:not([data-closing])) (nesting &::backdrop and @starting-style) so setting data-closing triggers the exit transition while the element remains in the top layer, then wait for getAnimations() to settle before calling .close() or .hidePopover():
:is(dialog[open], [popover]:popover-open):where(:not([data-closing])) {
opacity: 1;
@starting-style {
opacity: 0;
}
@media (prefers-reduced-motion: no-preference) {
transform: scale(1);
@starting-style {
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);
});
Popover light dismiss and popovertarget toggles cannot be intercepted (beforetoggle is only cancelable when opening), so in browsers that need the fallback those exits are instant. Provide an explicit close control that calls closeTopLayer() if the exit animation matters.
Fallbacks & browser support for Popover
The Popover API is mostly progressive enhancement, but its defining behaviors — top-layer promotion, light-dismiss, and popovertarget invocation — have no CSS-only equivalent. Older browsers need a polyfill, or a manual fallback if you would rather not ship one.
Polyfill: To support the popover attribute in older browsers, conditionally load @oddbird/popover-polyfill. MANDATORY: Feature detect by checking for the popover property on HTMLElement.prototype, and load the polyfill only when native support is missing — do NOT load it unconditionally.
With a bundler or import map:
// MANDATORY: Feature detect 'popover' on HTMLElement.prototype.
if (!Object.hasOwn(HTMLElement.prototype, "popover")) {
import("@oddbird/popover-polyfill");
}
If not using a package manager, import from a CDN, e.g. "https://unpkg.com/@oddbird/popover-polyfill@latest/dist/popover.min.js".
Caveats when using the polyfill:
- Do NOT use the
popoverIDL attribute for any code that may run before the polyfill is fully loaded. It will not be mirrored as an attribute, the popover will not be picked up by the polyfill, and the property will be shadowing the accessor the polyfill adds forever. UsesetAttribute()/removeAttribute()instead. - Styling caveat: The polyfill cannot define the real
:popover-openpseudo-class, so it applies a.\:popover-openclass instead. MANDATORY: Combine the two with:is()or:where(), otherwise browsers that lack:popover-opendiscard the entire rule:
[popover]:is(:popover-open, .\:popover-open) {
display: block;
}