Animating <dialog> Open and Close: Both Directions, No Library

The native <dialog> element does the hard parts of a modal for free: it moves into the top layer above everything else, makes the rest of the page inert, traps focus, closes on Escape, and restores focus to the trigger on close. What it does not do is animate. It snaps into existence and vanishes just as abruptly, because opening and closing flip display between none and block — a discrete property that ordinary transitions skip. The narrow problem this page solves is a dialog that fades and scales in and out, using only CSS on top of the element's built-in behaviour. It belongs to Dialog & Popover Animations in the CSS-Only Micro-Interactions & Animations guide.

Why the native element is worth animating

Custom modals built from <div> elements animate easily because nothing about them is special — but they must reimplement focus trapping, inertness, Escape handling, scroll locking and focus return, and most implementations get at least one wrong. The native dialog gets all of them right and is exposed correctly to assistive technology as a modal dialog. Trading those guarantees for easier animation is a bad deal.

Until recently the trade was necessary. Three platform features removed it: @starting-style gives an element a "before" state to transition from on its first frame; transition-behavior: allow-discrete lets display participate in a transition by flipping at the right moment rather than immediately; and the overlay property lets a closing element stay in the top layer until its exit transition finishes. Together they make both directions of a dialog animatable while the element keeps its native behaviour.

What happens when a dialog opens and closes

The open and close timeline On open, display and overlay switch on at the start while opacity and scale transition from the starting style to the open state. On close, opacity and scale transition back first, and display and overlay switch off only at the end, because allow-discrete delays them. Discrete properties flip at opposite ends showModal() close() display on at 0% off at 100% overlay opacity scale Without allow-discrete, display and overlay would drop at the start of close(), cutting the fade.

On open, the browser sets display: block and puts the dialog in the top layer immediately. Without help, the first frame would already be the final state, so there would be nothing to transition from — @starting-style supplies that "from" state. On close, the browser sets display: none and removes the dialog from the top layer. With allow-discrete on both display and overlay, those changes are deferred until the end of the transition, so the visual properties have time to animate out while the dialog is still rendered above the page.

The complete implementation

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Animated dialog</title>
<style>
  body { font: 16px/1.5 system-ui, sans-serif; margin: 2rem; }

  dialog {
    width: min(90vw, 28rem);
    padding: 1.5rem;
    border: 0;
    border-radius: 14px;
    box-shadow: 0 20px 50px rgb(15 23 42 / 0.3);

    /* CLOSED values (and the exit transition, which runs INTO this state). */
    opacity: 0;
    scale: 0.96;
    translate: 0 0.5rem;
    transition:
      opacity 160ms cubic-bezier(0.64, 0, 0.78, 0),
      scale 160ms cubic-bezier(0.64, 0, 0.78, 0),
      translate 160ms cubic-bezier(0.64, 0, 0.78, 0),
      /* Discrete: keep the dialog rendered and in the top layer until the
         exit has finished, instead of dropping it on the first frame. */
      display 160ms allow-discrete,
      overlay 160ms allow-discrete;
  }

  /* OPEN values, and the entry transition. */
  dialog[open] {
    opacity: 1;
    scale: 1;
    translate: 0 0;
    transition:
      opacity 240ms cubic-bezier(0.22, 1, 0.36, 1),
      scale 240ms cubic-bezier(0.22, 1, 0.36, 1),
      translate 240ms cubic-bezier(0.22, 1, 0.36, 1),
      display 240ms allow-discrete,
      overlay 240ms allow-discrete;
  }

  /* The state to animate FROM on the first frame after opening.
     Nested inside dialog[open], so it only applies at open time. */
  @starting-style {
    dialog[open] {
      opacity: 0;
      scale: 0.96;
      translate: 0 0.5rem;
    }
  }

  /* Reduced motion: keep a brief fade, drop the movement. */
  @media (prefers-reduced-motion: reduce) {
    dialog, dialog[open] { scale: 1; translate: 0 0; }
  }

  dialog button:focus-visible,
  .open:focus-visible { outline: 2px solid #4f46e5; outline-offset: 2px; }
</style>
</head>
<body>
  <button class="open" type="button" onclick="document.getElementById('confirm').showModal()">Delete project…</button>

  <dialog id="confirm" aria-labelledby="confirm-title">
    <h2 id="confirm-title">Delete this project?</h2>
    <p>This removes all files and cannot be undone.</p>
    <!-- method="dialog" closes the dialog on submit, no script needed. -->
    <form method="dialog">
      <button value="cancel" autofocus>Cancel</button>
      <button value="delete">Delete</button>
    </form>
  </dialog>
</body>
</html>

The live demo frames on this site run with scripting disabled, so the demo below applies the identical recipe to a popover — which opens from a popovertarget button without script — swapping [open] for :popover-open. Every transition, easing value and @starting-style block is the same as in the dialog code above, which shows how portable the pattern is across top-layer elements.

Live demoThe dialog recipe applied to a script-free popover
Click the button to open, then click outside or press Escape: the panel fades and scales in over 240ms and out over 160ms.

In the dialog version, the single line of script, showModal(), is what makes the dialog modal — inert page, focus trap, top layer. Closing needs no script: a <form method="dialog"> closes the dialog when submitted, and the value of the button that submitted it becomes the dialog's returnValue. The autofocus on Cancel puts initial focus on the safe choice, which is the right default for a destructive confirmation.

The key technique: two discrete properties, one @starting-style

Three pieces must all be present, and missing any one produces a characteristic failure.

What breaks when each piece is missing Missing starting-style: the dialog appears instantly with no entry. Missing display with allow-discrete: the dialog vanishes instantly on close. Missing overlay: the closing dialog fades out but falls behind other content and loses its backdrop for the duration. Each missing piece has its own symptom no @starting-style open: pops in instantly close: animates no display discrete open: animates close: vanishes on the first frame no overlay discrete open: animates close: fades behind page content All three together: symmetric, top-layer-safe open and close.

The overlay failure is the subtle one. A dialog removed from the top layer at the start of its exit is still visible for the transition, thanks to the deferred display, but it now renders in normal document order — so any positioned content with a higher z-index appears on top of it, and the ::backdrop, which only exists for top-layer elements, disappears immediately. Deferring overlay keeps both the stacking and the backdrop intact until the fade completes. Where overlay is unsupported, the declaration is ignored and the exit still works, just with that stacking glitch.

The per-state transitions follow the rule described in Entry vs Exit Easing: the dialog[open] rule controls opening, so it has the longer ease-out; the base dialog rule controls closing, so it has the shorter ease-in.

Closing paths and interrupted animations

A dialog can close in several ways, and each one should play the same exit. close() from script, a <form method="dialog"> submission, the Escape key and — for dialogs opened with closedby="any" where supported — a click on the backdrop all remove the open attribute, so all of them trigger the base-state transition. There is nothing extra to wire up, which is one of the quiet advantages of animating with transitions rather than with class-toggling scripts that only run on the paths someone remembered.

Interruptions are handled for free too. If a user opens the dialog and presses Escape 100 milliseconds later, the entry transition is cut short and the exit transition starts from the current, partly faded state, using the exit's curve and duration. Keyframe-based versions cannot do this: a running entry animation would be removed abruptly and the exit animation would start from its own first keyframe, producing a visible jump.

One path does need care: removing the dialog element from the DOM while it is open, as some frameworks do when unmounting a component. A removed element cannot finish a transition, so the dialog disappears instantly. Close the dialog first, wait for the transitionend event on opacity, and only then unmount.

Variation: growing from the trigger

A dialog that appears to grow out of the button that opened it gives users a strong sense of where it came from. Setting transform-origin toward the trigger is enough for a convincing effect, and the trigger's position can be passed in a custom property.

dialog {
  /* Set from the opener: el.style.setProperty('--origin-x', x + 'px') etc.
     Fallback is the centre of the dialog. */
  transform-origin: var(--origin-x, 50%) var(--origin-y, 50%);
}

@starting-style {
  dialog[open] {
    opacity: 0;
    scale: 0.6;              /* larger scale change reads as growing */
  }
}

@media (prefers-reduced-motion: reduce) {
  @starting-style {
    dialog[open] { scale: 1; }
  }
}

The origin must be expressed relative to the dialog's own box, so the opener computes the trigger's centre minus the dialog's final position. A cheaper approximation is to set the origin to the top of the dialog for triggers above it and the bottom for triggers below, which captures most of the effect. Keep the growth modest: a scale from 0.6 is a large, fast-moving change, and it is exactly the kind of motion reduced-motion users should not receive.

The backdrop can be animated alongside the dialog with the same technique, as shown in Backdrop Animations for Dialogs and Popovers.

Browser support

@starting-style is supported in Chrome and Edge 117+, Firefox 129+ and Safari 17.5+. transition-behavior: allow-discrete is supported in Chrome and Edge 117+, Firefox 129+ and Safari 17.4+. The overlay property is currently Chromium-only; elsewhere it is ignored and the exit animation still runs, with the stacking caveat described above. In engines without @starting-style, the dialog appears without an entry animation but remains fully functional. The standalone scale and translate properties are supported in current versions of all engines.

FAQ

Why does my dialog close instantly even though it animates open? Closing sets display to none and removes the dialog from the top layer immediately. To animate the close, both display and overlay must be listed in the transition with transition-behavior: allow-discrete, so the browser keeps the dialog rendered and in the top layer until the fade finishes.

What does the overlay property do?overlay reports whether an element is in the top layer. The browser sets it, not the author, but listing it in a transition with allow-discrete delays the element's removal from the top layer, which keeps a closing dialog above the page and its backdrop visible during the exit animation.

Should I animate the dialog with transitions or keyframes? Transitions are the better fit because they reverse cleanly when a dialog is closed mid-opening. Keyframe animations work for entry but need a separate exit animation and cannot be interrupted smoothly, so they suit one-way effects better than open-and-close pairs.

Does animating a dialog affect focus or accessibility? It should not. The browser moves focus into the dialog and makes the rest of the page inert as soon as showModal() runs, regardless of the visual animation. Keep the entry short so the dialog is readable quickly, and disable movement for reduced-motion users.

Related articles

More pages in the same section.