---
title: "Animate a Native Dialog in CSS with @starting-style"
description: "Animate a native dialog in and out with @starting-style and allow-discrete, fade its backdrop, close it on a backdrop click and respect reduced motion."
url: https://frontendhelpers.com/css-dialog-animation
updated: 2026-10-12
---

# Animate a native dialog: open and close in CSS

Keep everything the browser gives a modal `<dialog>` for free, then add an entry animation, an exit animation and a fading backdrop with a few lines of CSS and no animation library.

CSS recipe · By [AurumCodeWorks](https://frontendhelpers.com/about) · 7 min read · Updated 12 October 2026

## Discard this draft?

The panel scales up from 94% with a slight overshoot and leaves faster than it arrived. Press Esc, click the backdrop or use the button.

**Scale and fade** — 0.3s in with a soft overshoot, 0.2s out, backdrop fading alongside.

## Share this page

Pinned to the bottom edge, the sheet slides up from below the viewport and drops back down when it closes.

**Bottom sheet** — a `translateY(100%)` slide with a decelerating curve in and an accelerating one out.

## Settings saved

This one is a plain fade by default. The small upward drift is added only when the system says motion is welcome.

**Reduced motion aware** — opacity first; a 0.75rem rise only under `no-preference`.

## Why the native dialog is the right starting point

A modal is mostly behaviour, and the behaviour is the hard part. When you open a `<dialog>` with `showModal()`, the browser does five things that a hand-built `div` overlay has to fake. It promotes the dialog to the top layer, above every `z-index` and outside any `overflow: hidden` ancestor. It draws a `::backdrop` pseudo-element behind it that you can style. It moves focus inside. It closes on Esc. And it makes the rest of the page inert, so Tab cannot wander behind the modal and screen readers do not read the page beneath it. As MDN’s [dialog element reference](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/dialog) notes, a dialog opened this way is also exposed with `aria-modal="true"` automatically.

`show()` or the `open` attribute give you none of this. Animation never justifies swapping `showModal()` for a home-made overlay, because, as the rest of this recipe shows, every part of the modal can now be animated with CSS alone. Our [modal pop-in card](https://frontendhelpers.com/css-ui-feedback-animations#anim-modal-pop) shows the keyframe version of the motion; this page wires the same idea to the real element. For the wider checklist around keyboard and screen reader support, see our [accessible website guide](https://frontendhelpers.com/accessible-website-guide).

```html
<button type="button" class="dl-open" command="show-modal" commandfor="dl-pop">Open pop-in</button>
<dialog id="dl-pop" class="dl-dialog dl-pop" closedby="any" aria-labelledby="dl-pop-title">
  <div class="dl-body">
    <h2 id="dl-pop-title" class="dl-title">Discard this draft?</h2>
    <p class="dl-text">…</p>
    <form method="dialog"><button class="dl-close">Close</button></form>
  </div>
</dialog>
```

Three details in that markup do real work. `aria-labelledby` points at the heading, so a screen reader announces “Discard this draft?, dialog” when focus lands inside. The form with `method="dialog"` closes the dialog when its button is pressed, with no script. And the dialog’s own padding is zero, with the spacing on an inner `div`; that matters for the backdrop click later.

## Animate the opening with @starting-style

An element going from `display: none` to visible has no previous style to transition from, so by default a dialog simply appears. `@starting-style` supplies one: it declares the values the element should be treated as having just before its first style update. Put the closed look on the base rule, the open look on `[open]`, and repeat the closed look inside `@starting-style`.

```css
.dl-pop {
  opacity: 0;
  transform: scale(0.94);
  transition:
    opacity 0.2s ease-in,
    transform 0.2s ease-in,
    overlay 0.2s ease-in allow-discrete,
    display 0.2s ease-in allow-discrete;
}
.dl-pop[open] {
  opacity: 1;
  transform: scale(1);
  transition-duration: 0.3s;
  transition-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1);
}
@starting-style {
  .dl-pop[open] { opacity: 0; transform: scale(0.94); }
}
```

The `@starting-style` block has the same specificity as the rule it shadows, so it must come after it in the stylesheet; place it first and the open styles win, leaving nothing to animate. Chrome for Developers’ write-up on [entry and exit animations](https://developer.chrome.com/blog/entry-exit-animations) notes that Chrome has enforced this ordering since version 130.

A transition takes its timing from the style it is heading towards. Opening, the dialog moves to the `[open]` rule, so it uses 0.3 seconds and an overshooting curve that settles slightly past full size. Closing, it returns to the base rule and uses 0.2 seconds with `ease-in`. Quicker exits feel responsive: the user has already decided to leave.

We select `[open]` rather than the newer `:open` pseudo-class. They match the same dialogs, but MDN’s compatibility data lists `:open` only from Chrome 133, Firefox 136 and Safari 26.5, while the attribute selector works everywhere `<dialog>` does.

## Animate the close: transition display and overlay

Closing is where most attempts fail. `close()` does two abrupt things: it sets `display: none` and removes the dialog from the top layer. Either one ends the animation on the first frame. Both are fixed by listing the property in the transition with `allow-discrete`.

`display` is a discrete property: it has no in-between values. Normally discrete properties do not transition at all; with `allow-discrete` they flip at the halfway point. `display` is special-cased, as MDN’s [transition-behavior reference](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/transition-behavior) explains: going to `none`, it flips at 100% of the duration, so the dialog stays rendered while the opacity and scale finish. `overlay` is the browser-controlled property that says whether an element is in the top layer. Authors cannot set it, but listing it in the transition delays the removal by the same duration. Without it the dialog would drop back into the page, under the header and clipped by its parents, while it fades.

Give both the same duration as the visible properties. If `display` is shorter, the dialog disappears before the fade ends; if it is longer, an invisible dialog lingers and swallows clicks. Two more traps: if you write the longhand `transition-behavior`, it must come after the `transition` shorthand, which otherwise resets it; and never put `display: flex` or `grid` on the base dialog rule, because that overrides the browser’s `display: none` and shows the closed dialog. Lay out an inner wrapper instead, as the demos do with `.dl-body`.

## Fade the backdrop the same way

`::backdrop` is also a top-layer box that appears and disappears with the dialog, so it needs the identical treatment: a transparent base, a tinted open state, a matching `@starting-style`, and `overlay` and `display` with `allow-discrete`. Transition `background-color` rather than `opacity`, so the tint and its timing stay in one declaration. One backdrop rule serves all three demos.

```css
.dl-dialog::backdrop {
  background-color: rgb(2 6 23 / 0);
  transition:
    background-color 0.25s ease,
    overlay 0.25s allow-discrete,
    display 0.25s allow-discrete;
}
.dl-dialog[open]::backdrop { background-color: rgb(2 6 23 / 0.7); }
@starting-style {
  .dl-dialog[open]::backdrop { background-color: rgb(2 6 23 / 0); }
}
```

## A bottom sheet is the same pattern

Only the geometry and the curves change. The browser centres a modal dialog with `margin: auto`, so `margin: auto auto 0` pins it to the bottom edge, and the browser’s default `max-width` must be lifted to let it run full width on a phone. `translateY(100%)` moves the sheet down by its own height, whatever its content. It enters on a decelerating curve and leaves on an accelerating one.

```css
.dl-sheet {
  width: min(36rem, 100%);
  max-width: 100%;
  margin: auto auto 0;
  border-bottom: 0;
  border-radius: 16px 16px 0 0;
  transform: translateY(100%);
  transition:
    transform 0.25s cubic-bezier(0.4, 0, 1, 1),
    overlay 0.25s allow-discrete,
    display 0.25s allow-discrete;
}
.dl-sheet[open] {
  transform: translateY(0);
  transition-duration: 0.35s;
  transition-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
}
@starting-style {
  .dl-sheet[open] { transform: translateY(100%); }
}
```

The shared `.dl-dialog` rule also sets `position: fixed` and `inset: 0`. A modal dialog gets those from the browser anyway, but only while it is modal. In a browser that transitions `display` but not `overlay`, the closing dialog leaves the top layer straight away, and without that fixed position it would jump to its spot in the document mid-fade.

## Close on a backdrop click

Users expect a click outside a modal to dismiss it, which the platform calls light dismiss. The new `closedby` attribute switches it on: `closedby="any"` allows Esc, a mobile back gesture and a click outside; `closerequest`, the default for modals, allows Esc and back but not clicks; `none` leaves closing entirely to your code. Do not use `any` for a dialog that holds unsaved input, because a stray click would lose it.

Support is not complete, so the demos carry a fallback that runs only when `closedBy` is missing. A click on `::backdrop` is reported as a click on the dialog itself, and because the dialog has no padding, every click on its content lands on a descendant instead. The `pointerdown` check stops a text selection that starts inside and ends outside from closing the dialog, which mirrors the rule in the standard.

```js
const needsLightDismiss = !('closedBy' in HTMLDialogElement.prototype);
document.querySelectorAll('.dl-dialog').forEach((dialog) => {
  const trigger = document.querySelector(`[commandfor="${dialog.id}"]`);
  let pressedOutside = false;
  dialog.addEventListener('pointerdown', (e) => { pressedOutside = e.target === dialog; });
  dialog.addEventListener('click', (e) => {
    if (needsLightDismiss && pressedOutside && e.target === dialog) dialog.close();
  });
  dialog.addEventListener('close', () => trigger.focus());
});
```

The open buttons use the `command="show-modal"` and `commandfor` invoker attributes, which open the dialog without JavaScript in current Chrome, Edge, Firefox and Safari. For older versions, the demo script first checks for `command` on `HTMLButtonElement.prototype` and, if it is missing, adds a click listener that calls `showModal()`.

## Focus goes back to the trigger

You rarely need to manage focus by hand. The [HTML standard’s close steps](https://html.spec.whatwg.org/multipage/interactive-elements.html#close-the-dialog) record the focused element when `showModal()` runs and focus it again when a modal dialog closes, however it closed. On open, focus goes to the first focusable element inside, here the Close button, unless an `autofocus` attribute names something else.

The exception is Safari on macOS, where clicking a button does not focus it, so a mouse-opened dialog has no previous focus to restore. A long-standing WebKit bug records exactly this. The last line of the demo script covers it by focusing the trigger on `close`; in other browsers it simply repeats what has already happened.

## Reduced motion: fade, don’t move

Scaling and sliding are the movements most likely to bother someone with a vestibular disorder, while a short opacity change is not. There are two ways to write that. Demos one and two use an override: under `prefers-reduced-motion: reduce`, the transform is removed from the transition list and set to `none`, and the sheet gains a fade so it still has a visible exit.

```css
@media (prefers-reduced-motion: reduce) {
  .dl-pop,
  .dl-sheet {
    transform: none;
    transition:
      opacity 0.2s ease,
      overlay 0.2s allow-discrete,
      display 0.2s allow-discrete;
  }
  .dl-sheet { opacity: 0; }
  .dl-sheet[open] { opacity: 1; }
  @starting-style {
    .dl-sheet[open] { opacity: 0; }
  }
}
```

Demo three inverts it. The base rule is a plain fade, and the 0.75rem rise lives inside `prefers-reduced-motion: no-preference`, so a browser that does not report the setting falls back to the calm version. We prefer this for new components: motion is opted into, not remembered and removed. Never remove the transitions entirely: without them the dialog still works, but a short fade helps everyone see where the new layer came from.

```css
.dl-fade {
  opacity: 0;
  transition:
    opacity 0.25s ease,
    overlay 0.25s allow-discrete,
    display 0.25s allow-discrete;
}
.dl-fade[open] { opacity: 1; }
@starting-style {
  .dl-fade[open] { opacity: 0; }
}
@media (prefers-reduced-motion: no-preference) {
  .dl-fade {
    translate: 0 0.75rem;
    transition:
      opacity 0.25s ease,
      translate 0.25s ease,
      overlay 0.25s allow-discrete,
      display 0.25s allow-discrete;
  }
  .dl-fade[open] { translate: 0 0; }
  @starting-style {
    .dl-fade[open] { translate: 0 0.75rem; }
  }
}
```

## Browser support in October 2026

Everything here is progressive enhancement: each missing feature removes some polish and never the dialog itself. These are first-supported versions from MDN’s browser compatibility data, checked on 12 October 2026.

| Feature | Chrome / Edge | Safari | Firefox | What happens without it |
| --- | --- | --- | --- | --- |
| `<dialog>`, `showModal()`, `::backdrop` | 37 / 79 | 15.4 | 98  | No native modal; you would need a polyfill or library |
| `@starting-style` | 117 | 17.5 | 129 | The dialog appears instantly; closing is unaffected |
| `transition-behavior: allow-discrete` | 117 | 17.4 | 129 | The whole `transition` declaration is dropped, so no transitions at all |
| Transitioning `display` | 117 | 18  | Not yet | The dialog vanishes at once on close; opening still animates |
| `overlay` in transitions | 117 | Not yet | Not yet | The dialog leaves the top layer at once; the backdrop disappears immediately |
| `closedby` attribute | 134 | Technology Preview only | 141 | Backdrop clicks do nothing; the script fallback closes it |
| `command` / `commandfor` | 135 | 26.2 | 144 | The button does nothing; the script fallback calls `showModal()` |

In practice Chrome and Edge get every animation, Safari animates the opening and keeps the closing fade but takes the dialog out of the top layer at once, so the backdrop vanishes and the fading panel can be covered by positioned content, and Firefox animates the opening while closing stays instant. If you still support browsers older than 2024, MDN suggests declaring the transition once without `allow-discrete` and then again with it, so older engines keep the opacity and transform parts.

## Popover: the non-modal sibling

Not every layer should block the page. The `popover` attribute, supported since Chrome 114, Safari 17 and Firefox 125, also puts an element in the top layer and can be opened from a button with `popovertarget` and no script. An `auto` popover closes on Esc and on a click outside by default, but it does not make the page inert or trap focus, and it has no implicit dialog role, which is right for menus, pickers and toasts. It animates with exactly this recipe: swap `[open]` for `:popover-open` and keep the `@starting-style`, `overlay` and `display` lines. If the user must answer before doing anything else, use `showModal()`; otherwise, reach for a popover. More motion patterns for both live in our [UI feedback animations](https://frontendhelpers.com/css-ui-feedback-animations) collection.

## Frequently asked questions

### Why does my dialog vanish instantly instead of animating out?

Closing sets display: none and removes the dialog from the top layer at once, unless display and overlay are in the transition list with allow-discrete. Add both, with the same duration as the fade. Firefox does not yet transition display, so there the close stays instant even with correct CSS.

### Does closedby="any" work in every browser?

Not yet. MDN's compatibility data lists it in Chrome and Edge 134 and Firefox 141, but in Safari only in Technology Preview as of October 2026. Keep a small click handler that closes the dialog when a press starts and ends on the dialog element itself, and run it only when closedBy is missing.

### Do I need to move focus back to the button myself?

Usually not. The HTML standard records the focused element when showModal() runs and focuses it again when the modal closes. The exception is Safari on macOS, where clicking a button does not focus it, so there is nothing to return to. A one-line close listener that focuses the trigger covers that case.

### Should I use a dialog or a popover?

Use a dialog opened with showModal() when the user must deal with it before carrying on, such as a confirmation or a form. Use the popover attribute for non-modal layers such as menus, pickers and toasts, which should not make the rest of the page inert. Both animate with the same @starting-style pattern.

## Keep going

- [CSS UI feedback animations](https://frontendhelpers.com/css-ui-feedback-animations): Modal pop-ins, toasts, ripples and alerts, each with markup and CSS to copy.
 
- [Accessible website guide](https://frontendhelpers.com/accessible-website-guide): Keyboard access, focus handling and screen reader basics for every interactive component.
 
- [CSS animation performance](https://frontendhelpers.com/css-animation-performance): Why opacity and transform stay smooth while other properties repaint every frame.
