Years ago I did a demo where a modal was triggered open and it came flying in on a curved path. I always thought that was kinda cool. Time has chugged on, and I thought I’d revisit that with a variety of improved web platform technology.
- Instead of a
<div>
it’ll be a proper<dialog>
. - We’ll set it up to work with no JavaScript at all. But we’ll fall back to using the JavaScript methods
.showModal()
and.close()
to support browsers that don’t support the invoker command stuff. - We’ll use
@starting-style
, which is arguably more verbose, but allows for opening and closing animations while allowing the<dialog>
to bedisplay: none;
when closed which is better than it was before where the dialog was always in the accessibility tree. - Instead of
path()
for theoffset-path
, which forced us into pixels, we’ll useshape()
which allows us to use the viewport better. But we’ll still fall back topath()
. - We’ll continue accounting for
prefers-reduced-motion
however we need to.
Here’s where the refactor ends up:
1. Use a Dialog
The <dialog>
element is the correct semantic choice for this kind of UI, generally. But particularly if you are wanting to force the user to interact with the dialog before doing anything else (i.e. a “modal”) then <dialog>
is particularly good as it moves then traps focus within the dialog.
2. Progressively Enhanced Dialog Open and Close
I only just learned you can open a modal (in the proper “modal” state) without any JavaScript using invokers.
So you can do an “open” button like this, where command
is the literal command you have to call to open the modal and the commandfor
matches the id
of the dialog.
<button
command="show-modal"
commandfor="my-dialog"
>
Open Modal
</button>
Code language: HTML, XML (xml)
You may want to include popovertarget="my-dialog"
as well, which is a still-no-JS fallback that will open the modal in a non-modal state (no focus trap) in browsers that don’t support invokers yet. Buttttttttt, we’re going to need a JavaScript fallback anyway, so let’s skip it.
Here’s how a close button can be:
<button
command="close"
commandfor="my-dialog"
>
Close
</button>
Code language: HTML, XML (xml)
For browsers that don’t support that, we’ll use the <dialog>
element’s JavaScript API to do the job instead (use whatever selectors you need):
// For browsers that don't support the command/invokes/popup anything yet.
if (document.createElement("button").commandForElement === undefined) {
const dialog = document.querySelector("#my-dialog");
const openButton = document.querySelector("#open-button");
const closeButton = document.querySelector("#close-button");
openButton.addEventListener("click", () => {
dialog.showModal();
});
closeButton.addEventListener("click", () => {
dialog.close();
});
}
Code language: JavaScript (javascript)
At this point, we’ve got a proper dialog that opens and closes.
display: none;
3. Open & Close Animation while still using One thing about <dialog>
is that when it’s not open, it’s display: none;
automatically, without you having to add any additional styles to do that. Then when you open it (via invoker, method, or adding an open
attribute), it becomes display: block;
automatically.
For the past forever in CSS, it hasn’t been possible to run animations on elements between display: none
and other display values. The element instantly disappears, so when would that animation happen anyway? Well now you can. If you transition
the display
property and use the allow-discrete
keyword, it will ensure that property “flips” when appropriate. That is, it will immediately appear when transitioning away from being hidden and delay flipping until the end of the transition when transitioning into being hidden.
dialog {
transition: display 1.1s allow-discrete;
}
Code language: CSS (css)
But we’ll be adding to that transition, which is fine! For instance, to animate opacity on the way both in and out, we can do it like this:
dialog {
transition:
display 1.1s allow-discrete,
opacity 1.1s ease-out;
opacity: 0;
&[open] {
opacity: 1;
@starting-style {
opacity: 0;
}
}
}
I find that kinda awkward and repetitive, but that’s what it takes and the effect is worth it.
shape()
for the movement
4. Using The cool curved movement in the original movement was thanks to animating along an offset-path
. But I used offset-path: path()
which was the only practical thing available at the time. Now, path()
is all but replaced by the way-better-for-CSS shape()
function. There is no way with path()
to express something like “animate from the top left corner of the window to the middle”, because path()
deals in pixels which just can’t know how to do that on an arbitrary screen.
I’ll leave the path()
stuff in the to accommodate browsers not supporting shape()
yet, so it’ll end up like:
dialog {
...
@supports (offset-rotate: 0deg) {
offset-rotate: 0deg;
offset-path: path("M 250,100 S -300,500 -700,-200");
}
@supports (
offset-path: shape(from top left, curve to 50% 50% with 25% 100%)
) {
offset-path: shape(from top left, curve to 50% 50% with 25% 100%);
offset-distance: 0;
}
}
Code language: JavaScript (javascript)
That shape()
syntax expresses this movement:

Those points flex to whatever is going on in the viewport, unlike the pixel values in path()
. Fun!
This stuff is so new from a browser support perspective, I’m finding that Chrome 126, which is the stable version as I write, does support clip-path: shape()
, but doesn’t support offset-path: shape()
. Chrome Canary is at 128, and does support offset-path: shape()
. But the demo is coded such that it falls back to the original path()
by using @supports
tests.
Here’s a video of it working responsively:
5. Preferring Less Motion
I think this is kind of a good example of honoring the intention.
@media (prefers-reduced-motion) {
offset-path: none;
transition: display 0.25s allow-discrete, opacity 0.25s ease-out;
}
Code language: CSS (css)
With that, there is far less movement. But you still see the modal fade in (a bit quicker) which still might be a helpful animation emphasizing “this is leaving” or “this is entering”.