Dialog & Popover Animations: Motion for the Top Layer

Modal dialogs, popovers, menus, tooltips and toasts share a problem that ordinary components do not have: they appear from nothing and disappear into nothing. Their hidden state is display: none — or absence from the top layer altogether — and for most of CSS's history those states could not be animated at all. The result was an ecosystem of JavaScript libraries that manage mounting, unmounting and timing just to fade a modal in and out. This guide, part of CSS-Only Micro-Interactions & Animations, covers the platform features that remove the need: @starting-style, transition-behavior: allow-discrete, the overlay property and interpolate-size, applied to the native <dialog> element, the popover attribute and their ::backdrop.

Prerequisites — this guide assumes you can already:

  • Write per-state transitions and understand which state's transition applies (see CSS Transition Fundamentals).
  • Open a <dialog> with showModal() and a popover with popovertarget.
  • Position an element against another with anchor positioning.
  • Gate motion with prefers-reduced-motion.

The Core Concept: Discrete Changes With a Timeline

Opening a dialog or popover changes three kinds of property at once. Continuous properties — opacity, scale, translate, colours — interpolate smoothly when transitioned. Discrete properties — display, content-visibility, overlay — have no in-between values; they are either one thing or the other. And the element's first frame after becoming rendered has no previous style to transition from, because it was not rendered a frame ago.

The modern features each solve one of those problems:

  • @starting-style defines the style an element is considered to have before its first rendered frame, giving entry transitions a starting point.
  • transition-behavior: allow-discrete lets discrete properties participate in a transition. Instead of flipping at the start, a discrete property flips at the point that keeps the element visible: at the start when becoming visible, at the end when becoming hidden.
  • overlay is a browser-controlled property that reflects whether an element is in the top layer. Transitioning it with allow-discrete keeps a closing element in the top layer, above the page and with its backdrop, until its exit finishes.
  • interpolate-size lets intrinsic sizes such as auto participate in interpolation, so disclosure panels can grow to their content height.
Four features across the show and hide lifecycle A horizontal lifecycle from hidden to showing to shown to hiding to hidden. starting-style supplies the from-state at the showing step. allow-discrete defers display at the hiding step. overlay keeps top-layer placement during hiding. interpolate-size lets size animate to auto during showing and hiding. What each feature fixes, and when hidden showing shown hiding hidden @starting-style allow-discrete overlay interpolate-size (size to and from auto) Entry needs a from-state; exit needs discrete properties deferred.

The guides in this section apply those features to specific components. Animating dialog Open and Close covers the modal dialog in both directions. Popover Menus That Animate From Their Anchor adds spatial direction with anchor positioning. Animating ::backdrop handles the dimming layer beneath. Animating height: auto With interpolate-size solves the long-standing disclosure problem.

Why native elements beat custom overlays

It is worth being explicit about what these features protect. A modal built from <div> elements must reimplement focus trapping, background inertness, Escape to close, scroll locking, focus return, and correct semantics for assistive technology — and each of those has subtle failure modes that libraries have spent years fixing. The native <dialog> and popover give all of it for free, correctly, in every modern browser. Until recently teams gave that up to get animation. The features in this guide mean they no longer have to: the recommended architecture is now native element for behaviour, CSS for motion.

Choosing the element before the animation

Animation choices follow from the element, and the element follows from behaviour. Picking the wrong one leads to fighting its defaults — a popover that should have been a dialog will never make the page inert, however it is animated.

Which native element fits If the page must be blocked, use a modal dialog. If content expands in place, use details. Otherwise, if clicking outside should close it, use popover auto; if it should stay until dismissed, use popover manual. Behaviour first, then motion Must the page be blocked? yes no <dialog> + showModal() inert page, backdrop Does it expand in place? yes no, overlays <details> interpolate-size popover auto: light dismiss · manual: toasts Each element brings its own behaviour; the same motion recipe then applies to all of them.

Modal dialogs are for decisions that must be made before continuing: confirmations, required forms, destructive actions. Auto popovers are for transient, dismissible UI anchored to something: menus, pickers, rich tooltips. Manual popovers are for UI that should persist until explicitly closed: toasts, coach marks, non-blocking panels. And <details> is for content that expands in the document flow rather than overlaying it. Once that choice is made, the animation recipe on each is nearly identical — which is the payoff of learning the underlying features instead of per-component tricks.

Integration With Adjacent CSS

Anchor positioning. Popovers are in the top layer, so they escape their DOM parent's positioning context. Anchor positioning reattaches them spatially to their trigger, which is what makes directional entry animations meaningful.

Cascade layers. Overlay styles often need to override component defaults. Putting overlay motion in a dedicated layer, after components, makes that ordering explicit and avoids specificity escalation.

Custom properties. Durations and easings belong in tokens shared with the rest of the motion system, as described in Easing & Motion Design. ::backdrop inherits from its element in current engines, so backdrop colours can be tokens set on the dialog.

View transitions. For overlays that should morph out of the element that opened them — a card expanding into a full dialog — a same-document view transition can animate between the two states with a shared view-transition-name, while the dialog provides the modal behaviour.

Container queries. Content inside a dialog is often laid out with container queries against the dialog's own width, so a dialog that is narrow on phones and wide on desktops can rearrange its form fields without viewport media queries.


Syntax and Parameters

FeatureSyntaxDefault / notes
@starting-style@starting-style { selector { … } } or nested in a ruleApplies only on the first style update after an element becomes rendered
transition-behaviornormal | allow-discretenormal skips discrete properties
Shorthand formtransition: display 200ms allow-discreteKeyword can appear per transition item
overlaynone | auto (set by the browser)Authors transition it; they cannot set it
:popover-openpseudo-classMatches an open popover
[open] on <dialog>attributePresent while the dialog is shown
::backdroppseudo-elementExists only while its element is in the top layer
interpolate-sizenumeric-only | allow-keywordsInherited; set on :root
calc-size()calc-size(<basis>, <calc-sum>)Arithmetic on intrinsic sizes; interpolable
::details-contentpseudo-elementThe panel of a <details> element

Step-by-Step Implementation: A Toast Notification

A toast combines entry, automatic exit and the top layer, and makes a compact example of every piece.

Step 1: a manual popover as the container

<output class="toast" id="toast" popover="manual" role="status">Changes saved.</output>

popover="manual" means the toast is not light-dismissed and does not close other popovers, which suits notifications. role="status" and <output> make screen readers announce its text politely.

Step 2: place it and define the hidden state

.toast {
  position-area: block-end;        /* bottom centre of the viewport */
  margin: 0 0 1.5rem;
  padding: 0.75rem 1rem;
  border: 0;
  border-radius: 10px;
  background: #0f172a;
  color: #f8fafc;

  opacity: 0;
  translate: 0 1rem;
  transition:
    opacity 180ms ease-in,
    translate 180ms ease-in,
    display 180ms allow-discrete,
    overlay 180ms allow-discrete;
}

Step 3: define the shown state and its entry

.toast:popover-open {
  opacity: 1;
  translate: 0 0;
  transition:
    opacity 260ms cubic-bezier(0.22, 1, 0.36, 1),
    translate 260ms cubic-bezier(0.22, 1, 0.36, 1),
    display 260ms allow-discrete,
    overlay 260ms allow-discrete;
}

@starting-style {
  .toast:popover-open { opacity: 0; translate: 0 1rem; }
}

Step 4: show and hide from script

const toast = document.getElementById('toast');
toast.showPopover();
setTimeout(() => toast.hidePopover(), 5000);

Both calls simply toggle :popover-open; the transitions do the rest. No class names, no animation-end listeners and no unmount timing are needed, because the exit is driven entirely by the deferred discrete properties.

Step 5: respect reduced motion

@media (prefers-reduced-motion: reduce) {
  .toast, .toast:popover-open { translate: 0 0; }
}

The toast still fades so its arrival is perceptible, but it no longer travels.


Annotated Production Example: A Command Palette

A command palette — the keyboard-invoked search overlay common in developer tools — uses a modal dialog, a backdrop, a list that changes height as results filter, and a strong expectation of speed. The CSS below handles all of its motion.

Live demoCommand palette with fast entry, backdrop and resizing results
Open the palette, then press Escape or click outside: a 160ms entry and 120ms exit, fast enough to feel instant. (Opened here as a popover, since demos run without script.)
:root { interpolate-size: allow-keywords; }

.palette {
  width: min(92vw, 36rem);
  margin-block-start: 12vh;              /* palettes sit high, not centred */
  padding: 0;
  border: 0;
  border-radius: 14px;
  background: #ffffff;
  box-shadow: 0 24px 60px rgb(15 23 42 / 0.35);

  opacity: 0;
  scale: 0.98;
  transition:
    opacity 120ms ease-in,
    scale 120ms ease-in,
    display 120ms allow-discrete,
    overlay 120ms allow-discrete;
}

.palette[open] {
  opacity: 1;
  scale: 1;
  /* Fast: a palette is a tool, not an event. */
  transition:
    opacity 160ms cubic-bezier(0.22, 1, 0.36, 1),
    scale 160ms cubic-bezier(0.22, 1, 0.36, 1),
    display 160ms allow-discrete,
    overlay 160ms allow-discrete;
}

@starting-style {
  .palette[open] { opacity: 0; scale: 0.98; }
}

.palette::backdrop {
  background: rgb(15 23 42 / 0);
  transition: background-color 120ms ease-in, display 120ms allow-discrete, overlay 120ms allow-discrete;
}
.palette[open]::backdrop {
  background: rgb(15 23 42 / 0.35);
  transition: background-color 160ms ease-out, display 160ms allow-discrete, overlay 160ms allow-discrete;
}
@starting-style {
  .palette[open]::backdrop { background: rgb(15 23 42 / 0); }
}

/* The results list resizes as the user types; animate it gently. */
.palette__results {
  max-block-size: 50vh;
  overflow: auto;
  block-size: auto;
  transition: block-size 140ms ease-out;
}

@media (prefers-reduced-motion: reduce) {
  .palette, .palette[open] { scale: 1; }
  .palette__results { transition: none; }
}

Every duration is short — 120 to 160 milliseconds — because a command palette is summoned many times a day and must feel instant. Compare that with a destructive confirmation dialog, which appears rarely and can afford 240 milliseconds and a more noticeable scale: the right timing depends on how often the overlay appears and how much attention it deserves, not on the element type. The scale change is 2%, just enough to give the palette a sense of arriving without slowing it down. The results list animates its height as filtering changes the number of matches, which stops the dialog from jumping as the user types; with interpolate-size enabled, block-size: auto is the target. In browsers without it, the list simply resizes instantly, which is a perfectly acceptable result for a component whose main job is speed.


Performance and Accessibility Notes

Most of this is cheap. Opacity, scale and translate transitions on dialogs and popovers run on the compositor. The discrete properties cost nothing per frame; they flip once. The expensive exceptions are backdrop-filter blurs, especially when their radius animates, and size animations such as block-size, which re-run layout each frame.

Behaviour is independent of motion. Focus moves into a modal dialog, the page becomes inert, and screen readers announce the dialog as soon as showModal() runs, regardless of the visual transition. That is correct and should not be delayed to match the animation.

Exits should be short. A closing overlay that lingers visually after its content has become non-interactive confuses users who try to click it. Keep exits under about 200 milliseconds.

Reduced motion keeps the fade. A brief opacity change still communicates that something appeared; removing it entirely can make a dialog's arrival easy to miss. Remove movement, scale and blur, keep a short fade, as described in prefers-reduced-motion Recipes.

Announcements are separate from animation. Toasts need role="status" or a live region to be announced; animation cannot substitute for that.

Auto-dismiss timing is a content decision. A toast that disappears after five seconds must be readable in that time by slower readers and screen-reader users, and WCAG 2.2.1 Timing Adjustable asks that time limits can be extended or turned off unless they are essential. Keep important messages until dismissed, or pause the dismissal timer while the toast is hovered or focused. The exit animation should begin only when the timer has genuinely expired, never as a way of hinting that time is running out.

Stacking multiple overlays. Several open popovers or a popover over a dialog are stacked in the top layer in the order they were opened. Each has its own backdrop, and the backdrops accumulate: two dimmed backdrops produce a much darker page. Keep popover backdrops transparent, as recommended in the backdrop guide, so only the modal dialog dims the page however many overlays are open above it.


DevTools Debugging Workflow

  1. Force the state. In Chromium's Styles pane, add the open attribute to a dialog, or toggle :popover-open via the element state panel, to inspect the open styles without script.
  2. Slow the animation. Use the Animations panel at 10% speed and open the element. If the first frame already shows the final state, @starting-style is missing or its selector does not match the open state.
  3. Watch the exit. Close the element at slow speed. If it vanishes on the first frame, display is missing allow-discrete; if it fades behind other content or loses its backdrop, overlay is missing.
  4. Inspect the top layer. Chromium's Elements panel shows a #top-layer section listing elements currently in the top layer, and marks each with a top-layer badge, which confirms whether a closing element is still there.
  5. Check computed transitions. In the Computed pane, transition-behavior should read allow-discrete for the transition items that include discrete properties.

Browser Compatibility

FeatureChrome / EdgeFirefoxSafari
@starting-style117+129+17.5+
transition-behavior: allow-discrete117+129+17.4+
popover attribute114+125+17+
overlay propertyChromium onlyNot supportedNot supported
::details-content131+143+18.4+
interpolate-size / calc-size()129+Not supportedNot supported
Anchor positioning (anchor-name)125+147+26+

Everything here degrades to instant but fully functional behaviour: without @starting-style there is no entry animation, without allow-discrete the exit snaps, without overlay the exit briefly loses its backdrop, and without interpolate-size disclosures open instantly.


Common Pitfalls

PitfallCauseResolution
Element pops in with no entryNo @starting-style, or its selector misses the open stateNest it for [open] or :popover-open
Exit vanishes instantlydisplay not transitioned with allow-discreteAdd display … allow-discrete to the base-state transition
Closing dialog loses backdropoverlay not transitionedAdd overlay … allow-discrete
Accordion snaps openNo interpolate-size:root { interpolate-size: allow-keywords; } or grid 0fr → 1fr
Unmounted dialog skips its exitElement removed from the DOM while openClose first, remove after transitionend

FAQ

What is the top layer? A rendering layer above all normal page content, used by modal dialogs, popovers and fullscreen elements. Elements in it are not clipped by ancestors' overflow and ignore z-index on the page, which is why they can always appear on top without stacking-context workarounds.

Why are exit animations harder than entry animations for dialogs and popovers? Closing removes the element from rendering and from the top layer immediately. The entry has @starting-style to provide a from-state, but the exit needs display and overlay transitioned with allow-discrete so the element stays rendered and on top until its animation finishes.

Do I still need JavaScript to animate a dialog? Only to open a modal dialog, which requires showModal(). The animation itself, closing via a form with method=dialog, Escape handling and focus return are all native. Popovers need no script at all, because popovertarget buttons open and close them.

Should dialogs and popovers use the same animation? They can share easing and duration tokens, but their motion should differ in weight. Modal dialogs interrupt the user and can use a slightly longer, more noticeable entry with a dimmed backdrop; popovers are lightweight and should appear quickly from their anchor with no dimming.

How should these animations behave with reduced motion? Keep a short opacity fade so the change of state is still perceptible, and remove scale, translate and blur. Because the entry and exit are transitions, setting their durations to near zero in a reduced-motion media query is enough.

Related articles

More pages in the same section.