translateY(100%) slide with a decelerating curve in and an accelerating one out.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 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 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.
<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.
.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 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 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.
.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.
.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.
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 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.
@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.
.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 collection.