Frontend Masters Boost RSS Feed https://frontendmasters.com/blog Helping Your Journey to Senior Developer Wed, 17 Sep 2025 13:49:39 +0000 en-US hourly 1 https://wordpress.org/?v=6.8.3 225069128 CSS offset and animation-composition for Rotating Menus https://frontendmasters.com/blog/css-offset-and-animation-composition-for-rotating-menus/ https://frontendmasters.com/blog/css-offset-and-animation-composition-for-rotating-menus/#respond Wed, 17 Sep 2025 13:49:38 +0000 https://frontendmasters.com/blog/?p=7147 Circular menu design exists as a space-saver or choice, and there’s an easy and efficient way to create and animate it in CSS using offset and animation-composition. Here are some examples (click the button in the center of the choices):

I’ll take you through the second example to cover the basics.

The Layout

Just some semantic HTML here. Since we’re offering a menu of options, a <menu> seems appropriate (yes, <li> is correct as a child!) and each button is focusable.

<main>
  <div class="menu-wrapper">
    <menu>
      <li><button>Poland</button></li>
      <li><button>Brazil</button></li>
      <li><button>Qatar</button></li>
      <!-- etc. -->
    </menu>
    <button class="menu-button" onclick="revolve()">See More</button>
  </div>
</main>

Other important bits:

The menu and the menu button (<button id="menu-button">) are the same size and shape and stacked on top of each other.

Half of the menu is hidden via overflow: clip; and the menu wrapper being pulled upwards.

main { 
  overflow: clip;
}
.menu-wrapper { 
  display: grid;
  place-items: center;
  transform: translateY(-129px);
  menu, .menu-button {
    width: 259px;
    height: 129px;
    grid-area: 1 / 1;
    border-radius: 50%;
  }
}

Set the menu items (<li>s) around the <menu>’s center using offset.

menu {
    padding: 30px;
    --gap: 10%; /* The in-between gap for the 10 items */
}
li {
  offset: padding-box 0deg;
  offset-distance: calc((sibling-index() - 1) * var(--gap)); 
  /* or 
    &:nth-of-type(2) { offset-distance: calc(1 * var(--gap)); }
    &:nth-of-type(3) { offset-distance: calc(2 * var(--gap)); }
    etc...
  */
}

The offset (a longhand property) positions all the <li> elements along the <menu>’s padding-box that has been set as the offset path.

The offset CSS shorthand property sets all the properties required for animating an element along a defined path. The offset properties together help to define an offset transform, a transform that aligns a point in an element (offset-anchor) to an offset position (offset-position) on a path (offset-path) at various points along the path (offset-distance) and optionally rotates the element (offset-rotate) to follow the direction of the path. — MDN Web Docs

The offset-distance is set to spread the menu items along the path based on the given gap between them (--gap: 10%).

ItemsInitial value of offset-distance
10%
210%
320%

The Animation

@keyframes rev1 { 
  to {
    offset-distance: 50%;
  } 
}

@keyframes rev2 { 
  from {
    offset-distance: 50%;
  } 
  to {
    offset-distance: 0%;
  } 
}

Set two @keyframes animations to move the menu items halfway to the left, clockwise, (rev1), and then from that position back to the right (rev2)

li {
  /* ... */
  animation: 1s forwards;
  animation-composition: add; 
}

Set animation-time (1s) and animation-direction (forwards), and animation-composition (add) for the <li> elements

Even though animations can be triggered in CSS — for example, within a :checked state — since we’re using a <button>, the names of the animations will be set in the <button>’s click handler to trigger the animations.

By using animation-composition, the animations are made to add, not replace by default, the offset-distance values inside the @keyframes rulesets to the initial offset-distance values of each of the <li>.

ItemsInitial Valueto
10%(0% + 50%) 50%
210%(10% + 50%) 60%
320%(20% + 50%) 70%
rev1 animation w/ animation-composition: add
Itemsfromback to Initial Value
1(0% + 50%) 50%(0% + 0%) 0%
2(10% + 50%) 60%(10% + 0%) 10%
3(20% + 50%) 70%(20% + 0%) 20%
rev2 animation w/ animation-composition: add

Here’s how it would’ve been without animation-composition: add:

ItemsInitial Valueto
10%50%
210%50%
320%50%

The animation-composition CSS property specifies the composite operation to use when multiple animations affect the same property simultaneously.

MDN Web Docs

The Trigger

const LI = document.querySelectorAll('li');
let flag = true;
function revolve() {
  LI.forEach(li => li.style.animationName = flag ? "rev1" : "rev2");
  flag = !flag;
}

In the menu button’s click handler, revolve(), set the <li> elements’ animationName to rev1 and rev2, alternatively.

Assigning the animation name triggers the corresponding keyframes animation each time the <button> is clicked.

Using the method covered in this post, it’s possible to control how much along a revolution the elements are to move (demo one), and which direction. You can also experiment with different offset path shapes. You can declare (@keyframes) and trigger (:checked, :hover, etc.) the animations in CSS, or using JavaScript’s Web Animations API that includes the animation composition property.

]]>
https://frontendmasters.com/blog/css-offset-and-animation-composition-for-rotating-menus/feed/ 0 7147
The `-path` of Least Resistance (Part 2) https://frontendmasters.com/blog/the-path-of-least-resistance-part-2/ https://frontendmasters.com/blog/the-path-of-least-resistance-part-2/#comments Sat, 30 Aug 2025 05:47:10 +0000 https://frontendmasters.com/blog/?p=6976 In the previous chapter, we explored clip-path and its power to reshape elements, cutting through the rectangular constraints of traditional elements to create circles, polygons, and complex curved shapes. We learned how to think beyond the box (literally), but everything we covered was about static shapes. About defining boundaries and staying within them.

Now it’s time to break free from containment entirely. In this second part, we’re shifting from shapes that hold things in place to paths that guide movement. We’re moving from clip-path to offset-path, where your elements don’t get clipped into new shapes, they travel along custom routes.

Article Series

We talk about reduced motion for accessibility later in this post, but not all the demos in this post implement that media query as they are specifically demonstrating a concept. It’s up to you to figure out how best implement a reduced motion version of movement for your circumstances.

This isn’t about changing what your elements look like. It’s about changing how they move through space, creating motion that feels natural, intentional, and surprisingly expressive. Like these rounded squares moving along a heart-shaped path:

The above demo uses the shape() syntax which has less browser support than other features talked about in this series, like offset-path and clip-path.

Before the Motion

Let’s break down offset-path too. We already explored the concept of path extensively in the previous article, but what exactly does “offset” mean in this context?

Here’s a crucial difference from what we’ve learned previously. While clip-path works relative to the element’s own border-boxoffset-path works relative to the containing block that establishes the context for this element. The “offset” refers to the element’s position and orientation relative to that containing block, not its own dimensions.

This difference becomes clear when you see multiple elements following the same path. In this demo, three shapes travel along the exact same route. They all share the same offset-path: inset(10px), which creates a rectangular path 10 pixels inward from each edge of the containing block. Note how each shape follows this identical route, even though they have completely different dimensions:

Values and Coordinates

Just like with clip-path, you can define your offset paths using absolute units like pixels for precision, or relative units like percentages for responsive design, giving you granular control over how your paths relate to different parts of the containing block.

You can also use CSS variables to make your paths dynamic, allowing you to change the route based on user interactions or other conditions. You can plug a variable in as an entire path definition, a path function attribute, or a single numeric / coordinate inside a path function.

/* entire path definition */
offset-path: var(--route, none);

/* function attribute */
offset-path: circle(var(--radius, 50%));
offset-path: inset(10px var(--inline-inset, 20px) 20px);

/* single coordinate */
offset-path: polygon(0% 0%, var(--x2, 100%) 0%, 100% 100%, 0% 100%);
offset-path: shape(from 0% calc(var(--x1, 0px) + 10%), line to 100% 100%);

This makes motion paths highly parameterized and easy to orchestrate, and this flexibility is what makes offset-path so powerful for creating engaging, interactive experiences.

You can also use CSS variables on any of the companion properties (offset-distanceoffset-rotateoffset-anchoroffset-position) that will talk about next.

Traveling the Distance

In the previous examples, we’ve seen shapes moving along heart-shaped curves, simple rectangles, and basic circles. But what exactly is moving there? You might be surprised to learn that all of them use exactly the same keyframes:

@keyframes offset {
  0% { offset-distance: 0%; }
  100% { offset-distance: 100%; }
}

The offset-path is actually static, it defines the path itself. The offset-distance property determines where the shape sits along that path. The position can be set as an absolute value or as a percentage, where 0% is the starting point of the path and 100% is the end. It’s the animation between these values that creates the motion along the path.

Beyond Linear Motion

Of course, the animation doesn’t have to be linear from 0 to 100. You can move the shape along the path however you want by setting the position at the appropriate keyframe. Here’s an example where I move two stars on a star-shaped path. Both share the same offset-path, the red star’s animation is linear, and the cyan star uses additional keyframes that move it back and forth along the path.

/* Red star */
@keyframes offset1 {
  0% { offset-distance: 0%; }
  100% { offset-distance: 100%; }
}

/* Cyan star */
@keyframes offset2 {
  0% { offset-distance: 0%; }
  10% { offset-distance: 10%; }
  5%, 20% { offset-distance: 20%; }
  15%, 30% { offset-distance: 30%; }
  25%, 40% { offset-distance: 40%; }
  35%, 50% { offset-distance: 50%; }
  45%, 60% { offset-distance: 60%; }
  55%, 70% { offset-distance: 70%; }
  65%, 80% { offset-distance: 80%; }
  75%, 90% { offset-distance: 90%; }
  85%, 100% { offset-distance: 100%; }
  95% { offset-distance: 110%; }
}

Note that this animation uses a keyframe with offset-distance: 110%, and we’ll talk about negative and overflow distances later in this article.

Interactive Movement

But you’re not limited to keyframe animations. You can also use transition to smoothly animate the offset-distance property in response to different states and user interactions like hover, click, or focus. Like in this example where I set the offset-distance based on which element is being hovered.

Finding Your Anchor

Here’s something that trips up a lot of people when they first start working with offset-path: which part of your element actually travels along the path? By default, it’s the center of the element, but that’s not always what you want.

The offset-anchor property lets you specify which point on your element gets aligned with the path. You can anchor from any corner, edge, or specific coordinate within the element. It works just like transform-origin, accepting keywords like centertop left, or specific values like 75% 25%. This seemingly small detail can completely transform how your animations act and feel.

Setting the Starting Point

Another piece of the puzzle that’s often overlooked is deciding where the path begins. While offset-anchor controls which part of your element follows the path, offset-position determines where that path starts within the containing block.

This is particularly important when you’re using path functions that don’t specify their own starting position. The default value is normal, which places the starting point at the center of the containing block (50% 50%). But you can position it anywhere you want.

With offset-position: auto, the path uses the element’s own box position as the path’s origin. With something like offset-position: 60px 90px, the path starts from that specific position within the containing block, regardless of where the element itself is positioned.

This gives you incredible flexibility in designing motion paths. You can have multiple elements starting from different points but following similar route shapes, or create complex choreographed movements where the starting positions are as carefully controlled as the paths themselves.

Following the Flow

But there’s another crucial piece to making path animations feel natural: rotation. The offset-rotate property controls exactly this. It can automatically rotate your element to match the direction of the path at any given point, or you can set a fixed rotation, or combine both for more complex effects.

The magic keyword here is auto. When set, your element will always face the direction it’s traveling. As it moves around curves and corners, it rotates to stay aligned with the path’s tangent. You can set a fixed angle to override this automatic rotation, or combine a fixed angle with auto direction, like this: offset-rotate: auto 45deg. It means “face the direction of travel, but add an extra 45-degree twist.”

Here’s a perfect example to illustrate the different rotation behaviors. Four arrows travel along the same curved path, but each one demonstrates a different approach to rotation:

The red arrow uses the standard auto behavior, always pointing in the direction of travel. The green arrow ignores the path direction entirely with a fixed 60deg rotation. The cyan arrow combines both approaches with auto 30deg, following the path but with an additional 30-degree offset. And the purple arrow uses reverse, pointing backward along the path as if it’s being pulled rather than leading.

Working With Transforms

Here’s where things get really interesting from a technical perspective. When you use offset-path, you’re not positioning the elements, you’re actually transforming them into their place and angle, very much like using translate() and rotate(). This special type of CSS transform is called an “offset transform”, it’s a distinct layer in the transform stack, and it sits in a very specific position.

The transform order looks like this:

  1. Individual transform properties (translate, rotate, scale)
  2. Offset transform (our offset-path magic happens here)
  3. The transform property

This layering is crucial because it means offset-path transforms are applied after individual transform properties but before the transform property. This can significantly change the final visual result.

The first three use the individual transform properties: one translate, one rotate, one scale. The other three use the transform property: one translate(), one rotate(), one scale(). Because the individual transform properties run before the offset transform and the transform property runs after it, you get six different visual results from the same path.

Performance Considerations

The good thing about offset-path being a part of the transform stack is that it leverages the same hardware acceleration as other CSS transforms. The browser calculates the path geometry once, then efficiently interpolates positions and rotations as needed. No repaints or reflows.

But there are a few performance gotchas to watch out for. Avoid changing the offset-path itself during animations, as it forces expensive recalculations. Instead, animate offset-distance and use CSS variables or classes to switch between different paths.

Also, be mindful of path complexity. Don’t use shape() for a simple straight line, and remember that a circle() performs much better than a path() with hundreds of curve segments. If you’re seeing performance issues, consider simplifying your paths.

Closed vs Open Paths

There’s an important distinction in how different path functions behave when it comes to their start and end points. Some paths are inherently closed and cyclical, while others can be left open with distinct endpoints.

Path functions like circle()inset(), and polygon() always create closed paths. These are cyclical by nature, meaning the 100% position (the end) connects seamlessly back to the 0% position (the start). When an element travels along these paths, it forms a continuous loop without any jarring jumps or discontinuities.

In contrast, functions like path() and shape() give you explicit control over whether the path is closed or open. With these functions, you can choose to close the path (creating that seamless loop) or leave it open. When a path is left open, there’s a distinct gap between the endpoint and the starting point. If an element travels from 100% back to 0%, it will visually “jump” from the final position directly to the starting position.

In this example, all three shapes follow a similar path with just two lines, forming an inverted V shape. You can see that both the polygon() and the closed path() treat the gap between the last and first points as part of the path, even though it’s not explicitly defined that way. The middle path() remains open, so when it reaches the endpoint, it jumps directly back to the start.

Negative and Overflow Distances

This distinction between closed and open paths becomes particularly important when you start using offset-distance values outside the typical 0% to 100% range.

For closed paths, the cyclical nature means you can use any distance value, even negative numbers or values over 100%. Since the path loops back on itself, these values get normalized to their equivalent position within the 0-100% range. An offset-distance of 120% on a closed path is equivalent to 20%, and -15% becomes 85%. The element simply continues around the loop, making multiple revolutions if needed.

Open paths behave very differently. Here, distance values get clamped to the 0-100% range. Any value greater than 100% will position the element at the endpoint of the path, and any value less than 0% will keep it at the starting point. There’s no wrapping or continuation because there’s nowhere for the path to continue beyond its defined endpoints.

In this demo, you can play with the distance slider, which gives you a range from -50% to 150%, and see how the different paths respond.

This difference opens up interesting animation possibilities. With closed paths, you can create smooth multi-revolution animations by animating from 0% to values like 200% or 300%. With open paths, you might use values beyond the normal range to create pause effects at the endpoints, or to ensure the element stays put even if the animation overshoots.

Split Paths

We’ve seen the jump between the endpoint and starting point in open paths, and while that’s not always what we want, sometimes it’s exactly what we need.

Sometimes we need to interrupt the animation at one location and restart it at another, which wasn’t always straightforward until now. Using shape(), we can cut the motion of an animation in the middle of the path and restart it with a move command.

Here’s an example of a shape I created that’s cut in the middle.

offset-path: shape(
  from 80% 30%,
  curve to 100% 50% with 90% 20% / 105% 40%,
  curve to 100% 70% with 95% 60%,
  curve to 80% 90% with 105% 80% / 90% 100%,
  curve to 60% 90% with 70% 80%,
  move to 20% 70%, /* here's the cut */
  curve to 0% 50% with 10% 80% / -5% 60%,
  curve to 0% 30% with 5% 40%,
  curve to 20% 10% with -5% 20% / 10% 0%,
  curve to 40% 10% with 30% 20%
);

And here’s how it looks if we visualize the path itself:

Illustration of a motion path with labeled starting point, end point, and a movement command to a specific coordinate.

If we take this exact path, use it to move some circles, add some styling and perspective to make them look like a colorful twisting snake of balls, and add ‘gates’ as portals between the transition points, we get something like this:

Infinite Directions

As we’re getting closer to the end of our deep dive, let’s talk about something special that exists only in offset-path: the ray() function.

While most path functions define specific routes with clear start and end points, ray() takes a completely different approach. It creates an infinite straight line extending from a specified starting point in a given direction. Think of it as a laser beam that goes on forever.

offset-path: ray(var(--angle)); 

As you can see, the syntax is refreshingly simple. You put an <angle> inside the function, and that angle determines where the ray will point (0deg points right, 90deg points down).

So this covers the direction the ray is pointing, but if it’s an infinite line, what does 100% actually mean?

100% out of infinite

The default 100% is closest-side, which means the distance from the ray’s starting point to the closest side is 100%. We can define this distance using an optional keyword that controls how far the ray extends before the element reaches 100% distance.

There are five keywords in total: closest-sideclosest-cornerfarthest-sidefarthest-corner, and sides. To understand the difference between them, here’s an example where if you hover over the element, the mouse cursor position represents the ray’s starting position, and you can see what each keyword means relative to that position.

Notice that sides always stays constant, because it represents the distance to the element’s edge, regardless of the ray’s angle.

Here’s an example that uses the sides keyword. Here too, the mouse cursor position represents the ray’s center, and from there each star animates to the closest side and back. Hover over it to see how it reacts.

What makes ray() particularly interesting is that it’s always an open path, but unlike other open paths, there’s no defined endpoint. When you animate beyond 100%, the element just keeps traveling in that direction indefinitely. This makes it perfect for creating elements that fly off screen, laser effects, or directional animations that need to feel endless.

Reduced Motion

Just like any other animation, offset-path animations should respect user preferences and accessibility guidelines. When users have enabled reduced motion in their system settings, it’s important to either reduce or completely disable path animations accordingly. This ensures your interactive experiences remain accessible and comfortable for all users, including those who may experience motion sensitivity or vestibular disorders.

/*  animate only if the user has not expressed a preference for reduced motion */
@media (prefers-reduced-motion: no-preference) {
  .moving-shape {
    animation: offset 5s linear infinite;
  }
}

Wrapping Up

And just like that, we’ve completed our journey through the -path universe. What started in Part 1 as static shapes carved from rectangular constraints has evolved into something far more dynamic and expressive. We learned to think beyond the traditional box with clip-path, mastering the art of containment. Now with offset-path, we’ve transcended those boundaries entirely. Your elements no longer just exist in custom shapes, they dance through space along routes you design.

Together, these properties form a complete vocabulary for spatial expression in CSS. clip-path gives you control over form, while offset-path gives you control over motion. One carves space, the other travels through it. The combination unlocks interface animations that feel both natural and magical.

The path of least resistance isn’t always the straight line between two points. Sometimes it’s the beautiful curve that makes the journey more meaningful than the destination. And now you have the tools to create those curves, whether they contain your elements or carry them forward into new possibilities.

Article Series

]]>
https://frontendmasters.com/blog/the-path-of-least-resistance-part-2/feed/ 2 6976
Move Modal in on a… shape() https://frontendmasters.com/blog/move-modal-in-on-a-shape/ https://frontendmasters.com/blog/move-modal-in-on-a-shape/#comments Thu, 22 May 2025 18:27:26 +0000 https://frontendmasters.com/blog/?p=5917 , as we can do both open & close animations now.]]> 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>

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

]]>
https://frontendmasters.com/blog/move-modal-in-on-a-shape/feed/ 1 5917