Move Modal in on a… shape()

Chris Coyier Chris Coyier on

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.

  1. Instead of a <div> it’ll be a proper <dialog>.
  2. 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.
  3. We’ll use @starting-style, which is arguably more verbose, but allows for opening and closing animations while allowing the <dialog> to be display: none; when closed which is better than it was before where the dialog was always in the accessibility tree.
  4. Instead of path() for the offset-path, which forced us into pixels, we’ll use shape() which allows us to use the viewport better. But we’ll still fall back to path().
  5. 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.

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;
}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.

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;
  }
}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”.

Wanna learn CSS from a course?

Frontend Masters logo

FYI, we have a full CSS learning path with multiple courses depending on how you want to approach it.

7-Day Free Trial

Leave a Reply

Your email address will not be published. Required fields are marked *

Did you know?

Frontend Masters Donates to open source projects. $363,806 contributed to date.