Move Modal in on a⦠shape()
Move Modal in on a⦠shape() ź“ė Ø
Years ago I did a demo (chriscoyier
) 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:
CodePen Embed Fallback https://codepen.io/chriscoyier/pen/GggQrQq Move Modal In on Path (Next Gen!)
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>
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>
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();
});
}
At this point, weāve got a proper dialog that opens and closes.
3. Open & Close Animation while still using display: none;
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;
}
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.
4. Using shape()
for the movement
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;
}
}
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;
}
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ā.