Animating Popover and Dialog Entry and Exit

Part of CSS @starting-style & Entry Effects in Modern View Transitions & Scroll APIs.

The problem

The <dialog> element and the popover attribute give you correct focus management, correct dismissal behaviour and the top layer for free. What they do not give you is motion — and the first attempt at adding it produces the same result in every codebase: the dialog fades in nicely and then disappears instantly when it closes.

Adding a JavaScript timer to delay the close is the usual next move, and it introduces the class of bug that timers always introduce: the duration is written in two places, they drift apart, and on a slow frame the element is removed while it is still visible.

Root cause: three discrete properties, none of them waiting for you

A top-layer element’s visibility is governed by properties that change discretely rather than continuously.

display flips from block to none with no intermediate values. By default that change applies immediately, and an element with display: none is not rendered — so any transition still running on it is abandoned mid-frame.

overlay represents membership of the top layer. It is browser-controlled, and it also changes discretely. A dialog that closes leaves the top layer at once, which means that for the remainder of any exit animation it renders in normal document order — usually behind the very content it was covering.

content-visibility, where it is involved, behaves the same way.

transition-behavior: allow-discrete changes the timing of all three. With it, a discrete property that is transitioning waits until the other transitions on the element have finished before applying its new value. The fade runs, and only then does display become none and overlay become none.

The entry direction has the mirror-image problem, solved by @starting-style: an element that has just become rendered has no previous computed style, so there is nothing to interpolate from. The starting block supplies it.

What has to happen for a visible exitThree deferrals, all enabled by one declaration.What has to happen for a visible exitclose() calledState flips toclosedOpacitytransitionstartsdisplaydeferredallow-discreteoverlaydeferredallow-discreteElement removedafter the fade
Three deferrals, all enabled by one declaration.

Step-by-step resolution

Wiring both directionsFive declarations, no script beyond opening and closing.Wiring both directions1Put the closed appearance and the transition on the element's base rule.The transition applies in both directions2Put the open appearance on the [open] or :popover-open rule.3Add an @starting-style block for the open state with the from-values.The entry has a before-state to travel from4Add display and overlay to the transition list with allow-discrete.The exit becomes visible5Animate the backdrop separately, since it is a pseudo-element with its owntransition.
Five declarations, no script beyond opening and closing.

Production code pattern

/* Intent: a modal dialog that fades and lifts in, and reverses out, with the
   browser handling every timing decision. */
dialog.sheet {
  /* Closed appearance — also the from-state for the exit. */
  opacity: 0;
  translate: 0 8px;
  scale: 0.98;

  /* Declared on the element, so it applies opening AND closing. */
  transition:
    opacity 200ms cubic-bezier(0, 0, 0.2, 1),
    translate 200ms cubic-bezier(0, 0, 0.2, 1),
    scale 200ms cubic-bezier(0, 0, 0.2, 1),
    display 200ms allow-discrete,
    overlay 200ms allow-discrete;
}

dialog.sheet[open] {
  opacity: 1;
  translate: 0 0;
  scale: 1;
}

/* The from-state for the entry: without this the dialog is simply there. */
@starting-style {
  dialog.sheet[open] {
    opacity: 0;
    translate: 0 8px;
    scale: 0.98;
  }
}

/* The backdrop is a separate pseudo-element and needs its own set. */
dialog.sheet::backdrop {
  background: rgb(30 27 75 / 0%);
  transition: background 200ms linear, display 200ms allow-discrete,
              overlay 200ms allow-discrete;
}
dialog.sheet[open]::backdrop { background: rgb(30 27 75 / 42%); }

@starting-style {
  dialog.sheet[open]::backdrop { background: rgb(30 27 75 / 0%); }
}

/* Accessibility gate: the dialog still opens and closes correctly; it simply
   does so without travel or scale. */
@media (prefers-reduced-motion: reduce) {
  dialog.sheet {
    translate: 0 0;
    scale: 1;
    transition:
      opacity 100ms linear,
      display 100ms allow-discrete,
      overlay 100ms allow-discrete;
  }
  @starting-style {
    dialog.sheet[open] { opacity: 0; translate: 0 0; scale: 1; }
  }
}
// Intent: the only script required. No timers, no classes, no listeners.
openButton.addEventListener('click', () => dialog.showModal());
closeButton.addEventListener('click', () => dialog.close());

Rendering Impact: opacity, translate and scale — composite only. display and overlay are discrete and deferred, costing one style pass at each end; the backdrop’s colour change is a paint on a full-viewport layer, which is why it is a flat colour rather than a blur.

The backdrop deserves a note. It is tempting to put a backdrop-filter: blur() on it, and it is the single most expensive thing you can animate at viewport scale — see when filter and backdrop-filter are worth the paint cost. A flat translucent colour fades on the compositor; a blur re-samples everything behind it on every frame.

What each declaration is responsible forRemove any one of these and a specific, predictable failure appears.What each declaration is responsible forWithout itSymptomTransition on thebase ruleOnly the open state has itOpens smoothly, closes instantly@starting-styleblockNo from-stateAppears fully formed, no entrydisplay ...allow-discretedisplay applies at onceElement removed mid-exitoverlay ...allow-discreteLeaves the top layer at onceSlides behind page content whileclosingBackdrop rulesBackdrop is unstyledDim layer snaps in and out
Remove any one of these and a specific, predictable failure appears.

Verification checklist

Constraints and trade-offs

  • A long exit keeps a closed dialog in the DOM for its duration; keep exits under about 200 ms so rapid open/close cycles cannot overlap.
  • allow-discrete defers display only while other transitions are running — with no other transition, the flip is still immediate.
  • The backdrop cannot be targeted by most tooling and is easy to forget in a design review; screenshot it deliberately.
  • Animating the backdrop with a blur costs a full-viewport re-sample per frame and is the most common reason a dialog feels slow on mobile.
  • Engines without @starting-style show the dialog with no entry animation, which is an acceptable degradation as long as the resting styles are the visible ones.

Frequently asked questions

Why does my dialog animate open but vanish instantly on close?

Because display: none applies the moment the state changes, removing the element before the exit transition can run. transition-behavior: allow-discrete defers the display change until the other transitions on the element finish, which makes the exit visible.

What does the overlay property actually do?

It represents membership of the top layer. A closing dialog leaves the top layer as soon as it closes, which can put it behind page content mid-animation. Listing overlay in the transition with allow-discrete keeps it in the top layer until the animation completes.

Do I still need JavaScript for a dialog animation?

Only to open and close it — showModal() and close(), or the popover attributes. Once the CSS above is in place there is no timer to coordinate, no class to toggle after a duration, and no animationend listener.