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
Replace Your Animated GIFs with SVGs https://frontendmasters.com/blog/replace-your-animated-gifs-with-svgs/ https://frontendmasters.com/blog/replace-your-animated-gifs-with-svgs/#comments Mon, 15 Sep 2025 16:12:53 +0000 https://frontendmasters.com/blog/?p=7112 ` or `background-image`, making it a viable GIF replacement if you can pull it off! ]]> No one loves dancing hamster GIFs more than I do. But all those animated frames can add up to files so large you don’t even see the dancing hamster. Your other tab has already loaded and you’ve followed the dopamine hits down another social media rabbit hole.

There’s an alternative for those giant animated GIFs: animated SVGs.

Along with much smaller file size you also get infinite scalability and the use of some — though, sadly, not all — media queries. Let’s take a look.

Warning: some of the animations in this article do not use a prefers-reduced-motion media query. We’ll discuss why that is later in the article.

How it works

First let’s create a simple rhombus in SVG. You could do a square, but rhombus is more fun to say.

<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" clip-rule="evenodd" viewBox="0 0 500 500">
  <path id="rhombus" fill="#fc0000" d="m454 80-68 340H46l68-340h340Z"/>
</svg>

Next let’s do a quick spinning motion that we’ll run infinitely.

#rhombus {
  transform-origin: center;
  rotate: 0deg;
  animation: spinny-spin 3.5s forwards infinite ease-in-out;
}
@keyframes spinny-spin {
  0% {
    rotate: 0deg;
  }
  90%, 100% {
    rotate: 720deg;
  }
}

We’ve done this as essentially a separate CSS file that looks into the SVG to style parts of it. We could pluck up that CSS and put it right inside the <svg> if we wanted. SVG is cool with that.

<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" fill-rule="evenodd" stroke-linejoin="round"
    stroke-miterlimit="2" clip-rule="evenodd" viewBox="0 0 500 500">
    <style>
        #rhombus {
            transform-origin: center;
            rotate: 0deg;
            animation: spinny-spin 3.5s forwards infinite ease-in-out;
        }
        @keyframes spinny-spin {
            0% { rotate: 0deg; }
            90%, 100% { rotate: 720deg; }
        }
    </style>
    <path id="rhombus" fill="#fc0000" d="m454 80-68 340H46l68-340h340Z" />
</svg>

Now that the SVG is all one contained thing, we could save it as an independent file (let’s call it rhombus.svg) and load it using an <img> element:

<img src="rhombus.svg" alt="a spinning red rhombus">

Even when loaded in an img, the animation still runs (!):

This is why this technique is viable as a .gif replacement.

This technique works best for animations that move and/or transform the elements as opposed to a sprite-based or successive image animation (which is basically what an animated GIF is). Also, for security reasons, an SVG loaded through an img element can not load external files i.e., the sprite image. You could base64 the sprite and embed it, but it would likely increase the file size to animated GIF levels anyway.

Let’s look at a more complicated example:

Here’s a zombie playing an accordion (yes, it’s random, unless you know about my silly little site, then it’s still random, but not unexpected). On the left is the GIF version. On the right is the SVG.

GIF
SVG

As an animated GIF, this polka-playing image is about 353Kb in size, but as an animated SVG it’s just 6Kb, less than 2% of the GIF’s size. That’s massive size (performance) savings with the SVG, while looking crisper doing it.

I drew the character in a graphics program and outputted it as an SVG. I used Affinity Designer but you could use Adobe Illustrator, Inkscape, Figma, or anything else that exports SVG.

Side Note: In my export, I made certain to check the “reduce transformations” box in order to make it easier to animate it. If you don’t reduce transformations, the elements can appear in all kinds of cockamamie contortions: scaled, translated and rotated. This is fine if the element is static, but if you want to move it in any way with transformations, you’ll have to figure out how editing the transformations will affect your element. It almost certainly won’t be straightforward and may not even be decipherable. With reduced transformations, you get an element in its natural state. You can then transform it in whatever way you need to.

After outputting, I created the CSS animation using @keyframes then added that to an SVG style element (which works just about exactly the same as an HTML style element).

See Complete SVG File
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" clip-rule="evenodd" viewBox="0 20 250 440">
  <style>
    /* Music Note Animation */
    .musicnote {
      animation: 4s ease-in-out calc(var(--multiplier) * 1s - 1s) notes forwards infinite;
      opacity: 0;
    }
    /* These custom properties allow for some variation in the timing of each note so that there animations overlap and seem more random while also allowing one set of keyframes to be used for all seven notes */
    #n1 { --multiplier: 1 }
    #n2 { --multiplier: 1.2 }
    #n3 { --multiplier: 1.4 }
    #n4 { --multiplier: 1.6 }
    #n5 { --multiplier: 1.8 }
    #n6 { --multiplier: 2 }
    #n7 { --multiplier: 2.2 }
    @keyframes notes {
      /* move the notes up 2em while also varying their opacity */
      0% {
        opacity: 0;
        transform: translateY(0);
      }
      30%, 80% {
        opacity: 1;
      }
      100% {
        opacity: 0;
        transform: translateY(-2em);
      }
    }
    #zright, #zleft {
      /* Sets the initial state for each hand and side of the accordion */
      --multiplier: -1;
      transform-origin: 0 0;
      transform: translateX(0) rotate(0);
      animation: 4s ease-in-out 0s play forwards infinite;
    }
    #zleft {
      /* allows the same keyframes to be used for both sides by reversing the translation and rotation */
      --multiplier: 1;
    }
    @keyframes play {
      0%, 100% {
        transform: translateX(0) rotate(0);
      }
      50% {
        transform: translate(calc(var(--multiplier) * 31px), calc(var(--multiplier) * -1px)) rotate(calc(var(--multiplier) * 2deg));
      }
    }
    /* Animates the squeeze and stretch of the accordion bellows */
    #accord {
      animation: 4s linear 0s squeeze forwards infinite;
      transform-origin: center center;
      transform: scaleX(1);
    }
    @keyframes squeeze {
      0%, 100% {
        transform: scaleX(1);
      }
      50% {
        transform: scaleX(0.8);
      }
    }
  </style>
<g id="zombie">
  <!-- The main zombie head and body, everything except the hands -->
  <path fill="#676767" fill-rule="nonzero" d="M62 207h121v47H62z" />
  <path fill="#91c1a3" fill-rule="nonzero" d="M99 190h46v26H99z" />
  <path fill="#3a3a3a" fill-rule="nonzero" d="M156 87h10v19H78V87h9v-9h69v9Z" />
  <path fill="#9cd3b3" fill-rule="nonzero"
    d="M155 105h9v18h19v29h-10v27h-9v9h-9v10h-18v9h-29v-9H90v-10H80v-9h-9v-27h-9v-29h18v-18h10v-9h65v9Z" />
  <path id="eyes" fill="#fbeb8e" fill-rule="nonzero" d="M127 114h31v28h-31zm-37 0h28v28H90z" />
  <path fill="#758b7c" fill-rule="nonzero" d="M108 170h11v9h-11z" />
  <path fill="#91c1a3" fill-rule="nonzero" d="M118 133h9v28h-9z" />
  <path fill="#444445" fill-rule="nonzero" d="M90 123h9v9h-9zm46-9h9v9h-9z" />
  <path fill="#3a3a3a" fill-rule="nonzero" d="M164 102h9v39h-9zm-93 0h9v39h-9z" />
  <path fill="#676767" fill-rule="nonzero" d="M118 393v57H46v-37h34v-58h38v38Z" />
  <path fill="#9cd3b3" fill-rule="nonzero" d="M80 384h38v10H80z" />
  <path fill="#676767" fill-rule="nonzero" d="M128 393v-38h38v58h34v37h-72v-57Z" />
  <path fill="#9cd3b3" fill-rule="nonzero" d="M128 384h38v10h-38z" />
</g>
  <g id="accord">
<!-- THe accordion bellows -->
    <path fill="#9e6330"
      d="m191 201-20 7-25 7-20 4-25-2-25-7-24-3-14-2-18 147 23 9 24 6 29 5 30 4 25-6 27-8 29-1 17-9-19-152-14 1Z" />
    <path fill="#774b24"
      d="m107 214-10-1-6 162 10 1 6-162Zm14 2h10v162h-10zm31-5-10 1 4 162 10-1-4-162Zm23-6h-10l7 162h10l-7-162Zm20-8-10 1 17 166 10-1-17-166ZM81 208l-10-1-10 162h10l10-161Zm-20-5-10-1-16 162 10 1 16-162Z" />
  </g>
<g id="notes">
  <!-- The seven musical notes -->
  <path id="n7" class="musicnote" fill="#2f2f2f" fill-rule="nonzero"
    d="m200 153 5-23 2 1 2 1v1h2l-1 1h2l4 1 1 1v1h2l-1 2h1v1l2 1h1l-1 2-1-1v1h-4l1-1h-2v-1h-4l-1 1-3-1v-1h-2l-4 19h-1v2h-1l-1 1-1 1-2-1v1h-3v-1l-2-1v-1h-1l1-1h-1v-1h6v-1h2v-1h3v-1h-5v1h-2v-1l-1 1v-1l-3 1h-1l1-2h1v-2h1v-1h1v-1l2 1 1-1h3l-1 1 3 1-1 1h1Z" />
  <path id="n6" class="musicnote" fill="#2f2f2f" fill-rule="nonzero"
    d="m16 78 3-23h2v1h3v1h1v1h6v1h1v2h2l-1 2h1v1h2v1h1v1h-3v1h-2v-1h-2v-1h-2v1l-2-1v1h-4v-1h-2l-2 19h-1l-1 2h-1v1h-1v1h-2v1l-3-1v-1H7v-1H6v-1H5v-1h2v-1h5v-1h1v-1h3v-1h-4v1H5h1l-1 1v-2h1v-2h1v-1h1v-1h2l1-1h3l-1 1 3 1-1 1h1Z" />
  <path id="n5" class="musicnote" fill="#2f2f2f" fill-rule="nonzero"
    d="m40 111 1-7h2l-1 13h-1v2h-1v1h-1v1h-2v1h-4l1-1h-2v-1l-1-1v-1h-1v-2h-1v-3h1v-2h1v-1h1l1-1h2v-1l3 1v1h2v1Zm3-13-1 6h-2l1-16h2v1h3v1h1v1h6v1h1v1h2v2h1v1h1v1h1v1h-1v1h-4l-1-1h-5v1l-3-1h-2Z" />
  <path id="n4" class="musicnote" fill="#2f2f2f" fill-rule="nonzero"
    d="M220 79h2l1 1 1-1v1h2l-1-1h6v1h2v2h1l1 1 1-1v1h1l1 1h-2l1 1h-2v1h-4v-1l-2 1-2 1v1h-6l2 7 1-1 2 13h-1l1 2-1 1v1h-1v1h-2v1l-3 1v-1h-2v-1h-1v-1h-1l-1-2-1 1v-3l1-1-1-2h1v-1h1v-1h2v-1l3-1v1h2v1l1-1-1-6h-2l-3-16 2-1 1 1Z" />
  <path id="n3" class="musicnote" fill="#2f2f2f" fill-rule="nonzero"
    d="M226 183v-7h2l-1 13h-1v2h-1v1h-1v1h-2v1h-3v-1l-2-1v-1h-1v-1h-1v-2h-1v-3h1v-2h2v-1h1v-1h2v-1l3 1v1h2v1h1Zm2-14-1 7h-2l1-16h2l3 1v1h1v1h2v-1l4 1v1h1v1h2v2h1v1h1v1h1v1h-2v1h-3l1-1h-2l-3-1v1h-5v-1h-2Z" />
  <path id="n2" class="musicnote" fill="#2f2f2f" fill-rule="nonzero"
    d="m23 164 2 13h-1v2l-1 1 1 1h-1v1h-2v1l-3 1v-1h-2l-1-1h-1v-1h-1v-2h-1l-1-3h1v-2h1v-1h1v-1l2-1v-1h3v1h2v1l1-1-1-6 2-1Zm-1-15 2-1 1 1h1v1h1v-1l5-1v1h1v1h2v2h1l1 1 1-1v1h1v1h-1v1h-1v1h-2v-1l-2 1v-1l-3 1h1l-2 1-4 1v-1h-2m-2-9h-2l3 17 2-1-3-16Z" />
  <path id="n1" class="musicnote" fill="#2f2f2f" fill-rule="nonzero"
    d="m188 72 3 13h-1v2h-1v2l-2 1v1h-3v-1l-2 1-1-1h-1v-1h-1v-2l-1 1-1-3 1-1v-2h1l-1-1h1v-1l2-1v-1h3v1l2-1 1 1-1-7h2Zm-2-16h3v1l1-1v1h2l4-1h1l1 1h1l1 2h1v1l1-1 1 1h1v1h-1v1h-1v1h-4l-1-1-2 1-2 1v1h-5l1 7h-2l-3-16h2Z" />
</g>
  <g id="zleft">
    <!-- The left hand and left side of the accordion -->
    <path fill="#676767" d="m11 255-5-1-2 17v19l10 22 9-49-12-8Z" />
    <path fill="#9cd3b3" fill-rule="nonzero" d="m15 257 4-16-9-2-4 16 9 2Z" />
    <path fill="#581610" d="m11 294 7-87h6v-5h6l1-5h6v-5l6 1-16 184h-6v-5l-5-1v-5l-6-1v-5l-5-1 6-65Z" />
    <path fill="#9cd3b3" fill-rule="nonzero" d="m24 283-1 9-19-2 2-35 20 2v8l8 1-1 18-9-1Z" />
    <path fill="#330d09" d="M17 249h7l-5 51h-7l5-51Z" />
    <path fill="#48120d" d="m16 256 7 1-3 36-7-1 3-36Z" />
    <path fill="#6b6108" d="M19 251h3l-1 3-2-1v-2Zm-4 44h3l-1 3h-2v-3Z" />
  </g>
  <g id="zright">
    <!-- The right hand and right side of the accordion -->
    <path fill="#676767" d="M237 253h3l4 18 2 19-11 25-11-51 13-11Z" />
    <path fill="#9cd3b3" fill-rule="nonzero" d="m231 258-6-15 8-3 6 15-8 3Z" />
    <path fill="#581610" d="m224 366-27-176 6-1 1 5h6l1 5 6-1v5l6-1 14 89 8 55-5 2v5l-6 1 1 5-6 1 1 5-6 1Z" />
    <path fill="#9cd3b3" fill-rule="nonzero" d="m221 266-2-8 20-3 5 35-20 2-1-8-9 1-2-17 9-2Z" />
    <path fill="#330d09" d="m228 249-7 1 8 51 7-1-8-51Z" />
    <path fill="#48120d" d="m229 256-7 2 6 36 7-1-6-37Z" />
    <path fill="#6b6108" d="m226 251-3 1 1 3 3-1-1-3Zm7 44-3 1 1 3 3-1-1-3Z" />
  </g>
</svg>

Then, you save the SVG as a file and bring it into the webpage with an HTML img tag.

<img src="zombieaccordion.svg" alt="A zombie playing ear-bleeding notes on an accordion">

Background Images

These animated SVGs don’t just work in the img element, they also work as a CSS background-image. So you can have a hundred little zombies playing the accordion in your background. That said, repeating the animation potentially infinitely takes a hit on page performance. For instance, during testing, when I had the zombie playing as a background image, another copy of the SVG in an img element struggled to animate.

Media Queries

Some media queries from within the SVG pierce the vale and work normally! Queries for width, height and even prefers-color-scheme worked just fine. But it’s a mixed bag. I couldn’t get print, pointer or, worst of all, prefers-reduced-motion to work. But those media queries that do work can give you even more flexibility in how you work with these animated SVGs.

Using @media (max-width: 300px), the animation below only plays when the img is 300 pixels wide or larger. To be clear, the max-width media query is based on the size of the img element, and not the size of the screen.

Plus media queries work even in background images! They can be a little trickier because, for instance, the width queries work on the size the image appears at, not the size of the container.

Gotchas

While most of this works the way any other CSS animation would, there are some limitations to how the CSS works in the SVG file shown in img vs. how it would work embedded in the HTML directly. As replaced content, the SVG is in a sort of sandbox and cannot access much outside the file.

  • The animation has to run automatically. You can’t use hover effects or clicks to start the animation.
  • Despite width and height media queries working within the SVG, viewport units do not work within the SVG.
  • As mentioned above, the animation won’t recognize prefers-reduced-motion, whether the prefers-reduced-motion declaration is within the SVG or in the larger site. While neither would an animated GIF recognize it, it unfortunately won’t give you that additional built-in functionality. On the plus side, any system you had that would prevent an animated GIF from playing should be easily modifiable to also apply to the SVG.
  • The SVG won’t run JavaScript from within the SVG. While a GIF wouldn’t run JavaScript either, I had hoped to get around prefers-reduced-motion not working by implementing it with JavaScript, but that too didn’t work. It’s probably a good thing it doesn’t, though, as that would be a massive security hole.
  • Modern CSS may or may not work. I was delighted to see custom properties and nested selectors working fine in my tests, but exactly what modern features are available and what may not work (like prefers-reduced-motion) will require more testing.

This technique works in all versions of the latest browsers and should theoretically work as far back as style elements and CSS animations are supported in SVG.

Alright let’s get those hamsters… errr… zombies dancing!

]]>
https://frontendmasters.com/blog/replace-your-animated-gifs-with-svgs/feed/ 1 7112
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
Infinite Marquee Animation using Modern CSS https://frontendmasters.com/blog/infinite-marquee-animation-using-modern-css/ https://frontendmasters.com/blog/infinite-marquee-animation-using-modern-css/#comments Mon, 04 Aug 2025 18:30:28 +0000 https://frontendmasters.com/blog/?p=6673 A set of logos with an infinite repeating slide animation is a classic component in web development. We can find countless examples and implementations starting from the old (and now deprecated) <marquee> element. I’ve written an article about it myself a few years ago.

“Why another article?” you ask. CSS keeps evolving with new and powerful features, so I always try to find room for improvement and optimization. We’ll do that now with some new CSS features.

At the time of writing, only Chrome-based browsers have the full support of the features we will be using, which include features like shape(), sibling-index(), and sibling-count().

In the demo above, we have an infinite marquee animation that works with any number of images. Simply add as many elements as you want in the HTML. There is no need to touch the CSS. You can easily control the number of visible images by adjusting one variable, and it’s responsive. Resize the screen and see how things adjust smoothly.

You might think the code is lengthy and full of complex calculations, but it’s less than 10 lines of CSS with no JavaScript.

.container {
  --s: 150px; /* size of the images */
  --d: 8s; /* animation duration */
  --n: 4; /* number of visible images */
  
  display: flex;
  overflow: hidden;
}
img {
  width: var(--s);
  offset: shape(from calc(var(--s)/-2) 50%,hline by calc(sibling-count()*max(100%/var(--n),var(--s))));
  animation: x var(--d) linear infinite calc(-1*sibling-index()*var(--d)/sibling-count());
}
@keyframes x { 
  to { offset-distance: 100%; }
}

Perhaps this looks complex at first glance, especially that strange offset property! Don’t stare too much at it; we will dissect it together, and by the end of the article, it will look quite easy.

The Idea

The tricky part when creating a marquee is to have that cyclic animation where each element needs to “jump” to the beginning to slide again. Earlier implementations will duplicate the elements to simulate the infinite animation, but that’s not a good approach as it requires you to manipulate the HTML, and you may have accessibility/performance issues.

Some modern implementations rely on a complex translate animation to create the “jump” of the element outside the visible area (the user doesn’t see it) while having a continuous movement inside the visible area. This approach is perfect but requires some complex calculation and may depend on the number of elements you have in your HTML.

It would be perfect if we could have a native way to create a continuous animation with the “jump” and, at the same time, make it work with any number of elements. The first part is doable and we don’t need modern CSS for it. We can use offset combined with path() where the path will be a straight line.

Inside path, I am using the SVG syntax to define a line, and I simply move the image along that line by animating offset-distance between 0% and 100%. This looks perfect at first glance since we have the animation we want but it’s not a flexible approach because path() accepts only hard-coded pixel values.

To overcome the limitation of path(), we are going to use the new shape() function! Here is a quote from the specification:

The shape() function uses a set of commands roughly equivalent to the ones used by path(), but does so with more standard CSS syntax, and allows the full range of CSS functionality, such as additional units and math functions … In that sense, shape() is a superset of path().

Instead of drawing a line using path(), we are going to use shape() to have the ability to rely on CSS and control the line based on the number of elements.

Here is the previous demo using shape():

If you are unfamiliar with shape(), don’t worry. Our use case is pretty basic as we are going to simply draw a horizontal line using the following syntax:

offset: shape(from X Y, hline by length);

The goal is to find the X Y values (the coordinates of the starting point) and the length value (the length of the line).

The Implementation

Let’s start with the HTML structure, which is a set of images inside a container:

<div class="container">
  <img src="">
  <img src="">
  <!-- as many images as you want -->
</div>

We make the container flexbox to remove the default space between the image and make sure they don’t wrap even if the container is smaller (remember that flex-wrap is by default nowrap).

Now, let’s suppose we want to see only N images at a time. For this, we need to define the width of the container to be equal to N x size_of_image.

.container {
  --s: 100px; /* size of the image */
  --n: 4; /* number of visible images */

  display: flex;
  width: calc(var(--n) * var(--s));
  overflow: hidden;
}
img {
  width: var(--s);
}

Nothing complex so far. We introduced some variables to control the size and the number of visible images. Now let’s move to the animation.

To have a continuous animation, the length of the line needs to be equal to the total number of images multiplied by the size of one image. In other words, we should have a line that can contain all the images side by side. The offset property is defined on the image elements, and thanks to modern CSS, we can rely on the new sibling-count() to get the total number of images.

offset: shape(from X Y, hline by calc(sibling-count() * var(--s)));

What about the X Y values? Let’s try 0 0 and see what happens:

Hmm, not quite good. All the images are above each other, and their position is a bit off. The first issue is logical since they share the same animation. We will fix it later by introducing a delay.

The trickiest part when working with offset is defining the position. The property is applied on the child elements (the images in our case), but the reference is the parent container. By specifying 0 0, we are considering the top-left corner of the parent as the starting point of the line.

What about the images? How are they placed? If you remove the animation and keep the offset-distance equal to 0% (the default value), you will see the following.

An animated marquee with text that moves horizontally across a container, showcasing a modern CSS implementation for infinite scrolling images or text.

The center of the images is placed at the 0 0, and starting from there, they move horizontally until the end of the line. Let’s update the X Y values to rectify the position of the line and bring the images inside the container. For this, the line needs to be in the middle 0 50%.

offset: shape(from 0 50%, hline by calc(sibling-count() * var(--s)));

It’s better, and we can already see the continuous animation. It’s still not perfect because we can see the “jump” of the image on the left. We need to update the position of the line so it starts outside the container and we don’t see the “jump” of the images. The X value should be equal to -S/2 instead of 0.

offset: shape(from calc(var(--s)/-2) 50%, hline by calc(sibling-count() * var(--s)));

No more visible jump, the animation is perfect!

To fix the overlap between the images, we need to consider a different delay for each image. We can use nth-child() to select each image individually and define the delay following the logic below:

img:nth-child(1) {animation-delay: -1 *  duration/total_image }
img:nth-child(2) {animation-delay: -2 *  duration/total_image }
/* and so on */

Tedious work, right? And we need as many selectors as the number of images in the HTML code, which is not good. What we want is a generic CSS code that doesn’t depend on the HTML structure (the number of images).

Similar to the sibling-count()that gives us the total number of images, we also have sibling-index() that gives us the index of each image within the container. All we have to do is to update the animation property and include the delay using the index value that will be different for each image, hence a different delay for each image!

animation: 
  x var(--d) linear infinite 
  calc(-1*sibling-index()*var(--d)/sibling-count());

Everything is perfect! The final code is as follows:

.container {
  --s: 100px; /* size of the image */
  --d: 4s; /* animation duration */
  --n: 4; /* number of visible images */
  
  display: flex;
  width: calc(var(--n) * var(--s));
  overflow: hidden;
}
img {
  width: var(--s);
  offset: shape(from calc(var(--s)/-2) 50%, hline by calc(sibling-count() * var(--s)));
  animation: x var(--d) linear infinite calc(-1*sibling-index()*var(--d)/sibling-count());
}
@keyframes x { 
  to {offset-distance: 100%}
}

We barely have 10 lines of CSS with no hardcoded values or magic numbers!

Let’s Make it Responsive

In the previous example, we fixed the width of the container to accommodate the number of images we want to show but what about a responsive behavior where the container width is unknown? We want to show only N images at a time within a container that doesn’t have a fixed width.

The observation we can make is that if the container width is bigger than NxS, we will have space between images, which means that the line defined by shape() needs to be longer as it should contain the extra space. The goal is to find the new length of the line.

Having N images visible at a time means that we can express the width of the container as follows:

width = N x (image_size + space_around_image)

We know the size of the image and N (Defined by --s and --n), so the space will depend on the container width. The bigger the container is, the more space we have. That space needs to be included in the length of the line.

Instead of:

hline by calc(sibling-count() * var(--s))

We need to use:

hline by calc(sibling-count() * (var(--s) + space_around_image))

We use the formula of the container width and replace (var(--s) + space_around_image) with width / var(--n) and get the following:

hline by calc(sibling-count() * width / var(--n) )

Hmm, what about the width value? It’s unknown, so how do we find it?

The width is nothing but 100%! Remember that offset considers the parent container as the reference for its calculation so 100% is relative to the parent dimension. We are drawing a horizontal line thus 100% will resolve to the container width.

The new offset value will be equal to:

shape(from calc(var(--s)/-2) 50%, hline by calc(sibling-count() * 100% / var(--n)));

And our animation is now responsive.

Resize the container (or the screen) in the below demo and see the magic in play:

We have the responsive part but it’s still not perfect because if the container is too small, the images will overlap each other.

We can fix this by combining the new code with the previous one. The idea is to make sure the length of the line is at least equal to the total number of images multiplied by the size of one image. Remember, it’s the length that allows all the images to be contained within the line without overlap.

So we update the following part:

calc(sibling-count() * 100%/var(--n))

With:

max(sibling-count() * 100%/var(--n), sibling-count() * var(--s))

The first argument of max() is the responsive length, and the second one is the fixed length. If the first value is smaller than the second, we will use the latter and the images will not overlap.

We can still optimize the code a little as follows:

calc(sibling-count() * max(100%/var(--n),var(--s)))

We can also add a small amount to the fixed length that will play the role of the minimum gap between images and prevent them from touching each other:

calc(sibling-count() * max(100%/var(--n),var(--s) + 10px))

We are done! A fully responsive marquee animation using modern CSS.

Here is again the demo I shared at the beginning of the article with all the adjustments made:

Do you still see the code as a complex one? I hope not!

The use of min() or max() is not always intuitive, but I have a small tutorial that can help you identify which one to use.

More Examples

I used images to explain the technique, but we can easily extend it to any kind of content. The only requirement/limitation is to have equal-width items.

We can have some text animations:

Or more complex elements with image + text:

In both examples, I am using flex-shrink: 0 to avoid the default shrinking effect of the flex items when the container gets smaller. We didn’t have this issue with images as they won’t shrink past their defined size.

Conclusion

Some of you will probably never need a marquee animation, but it was a good opportunity to explore modern features that can be useful such as the shape() and the sibling-*() functions. Not to mention the use of CSS variables, calc(), max(), etc., which I still consider part of modern CSS even if they are more common.

]]>
https://frontendmasters.com/blog/infinite-marquee-animation-using-modern-css/feed/ 6 6673
Stacked Transforms https://frontendmasters.com/blog/stacked-transforms/ https://frontendmasters.com/blog/stacked-transforms/#comments Tue, 15 Jul 2025 23:38:20 +0000 https://frontendmasters.com/blog/?p=6457 I think the best way for me to show you what I want to show you is to make this blog post a bit like a story. So I’m gonna do that.


So I’m at CSS Day in Amsterdam this past month, and there was a lovely side event called CSS Café. I’m 90% sure it was during a talk by Johannes Odland and a coworker of his at NRK (whose name I embarrassingly cannot remember) where they showed off something like an illustration of a buoy floating in the water with waves in front of it. Somehow, someway, the CSS property animation-composition was involved, and I was like what the heck is that? I took notes during the presentation, and my notes simply said “animation-composition”, which wasn’t exactly helpful.

I nearly forgot about it when I read Josh Comeau’s blog post Partial Keyframes, where he talks about “dynamic, composable CSS keyframes”, which, as I recall was similar to what Johannes was talking about. There is some interesting stuff in Josh’s post — I liked the stuff about comma-separating multiple animations — but alas, nothing about animation-composition.

So I figured I’d stream about it, and so I did that, where I literally read the animation-composition docs on MDN and played with things. I found their basic/weird demo intriguing and learned from that. Say you’ve got a thing and it’s got some transfoms already on it:

.thing {
  transform: translateX(50px) rotate(20deg);
}

Then you put a @keyframes animation on it also:

.thing {
  transform: translateX(50px) rotate(20deg);
  animation: doAnimation 5s infinite alternate;
}

@keyframes doAnimation {
  from {
    transform: translateX(0)
  }
  to {
    transform: translateX(100px)
  }
}

Pop quiz: what is the translateX() value going to be at the beginning of that animation?

It’s not a trick question. If you intuition tells you that it’s going to be translateX(0), you’re right. The “new” transform in the @keyframes is going to “wipe out” any existing transform on that element and replace it with what is described in the @keyframes animation.

That’s because the default behavior is animation-composition: replace;. It’s a perfectly fine default and likely what you’re used to doing.

But there are other possible values for animation-composition that behave differently, and we’ll look at those in a second. But first, the fact that transform can take a “space-separated” list of values is already kind of interesting. When you do transform: translateX(50px) rotate(20deg);, both of those values are going to apply. That’s also relatively intuitive once you know it’s possible.

What is less intuitive but very interesting is that you can keep going with more space-separated values, even repeating ones that are already there. And there I definitely learned something! Say we tack on another translateX() value onto it:

.thing {
  transform: translateX(50px) rotate(20deg) translateX(50px);
}

My brain goes: oh, it’s probably basically the same as translateX(100px) rotate(20deg);. But that’s not true. The transforms apply one at a time, and in order. So what actually happens is:

Illustration depicting three rectangles with arrows indicating movement and rotation, labeled with numbers 1, 2, and 3, on a dotted background.

I’m starting to get this in my head, so I streamed again the next day and put it to work.

What popped into my head was a computer language called Logo that I played with as a kid in elementary school. Just look at the main image from the Wikipedia page. And the homepage of the manual is very nostoligic for me.

Cover of the LEGO TC logo book titled 'Teaching the Turtle,' featuring a blue and red LEGO robotic structure on a baseplate with a computer in the background.

We can totally make a “turtle” move like that.

All I did here is put a couple of buttons on the page that append more transform values to this turtle element. And sure enough, it moves around just like the turtle of my childhood.

But Mr. Turtle there doesn’t really have anything to do with animation-composition, which was the origin of this whole story. But it’s sets up understanding what happens with animation-composition. Remember this setup?

.thing {
  transform: translateX(50px) rotate(20deg);
  animation: doAnimation 5s infinite alternate;
}

@keyframes doAnimation {
  from {
    transform: translateX(0)
  }
  to {
    transform: translateX(100px)
  }
}

The big question is: what happens to the transform that is already on the element when the @keyframes run?

If we add animation-composition: add; it adds what is going on in the @keyframes to what is already there, by appending to the end of the list, as it were.

.thing {
  transform: translateX(50px) rotate(20deg);
  animation: doAnimation 5s infinite alternate;
  animation-composition: add;
}

@keyframes doAnimation {
  from {
    transform: translateX(0);
    /* starts as if: 
       transform: translateX(50px) rotate(20deg) translateX(0); */
  }
  to {
    transform: translateX(100px);
    /* ends as if:
      transform: translateX(50px) rotate(20deg) translateX(100px); */
  }
}

If we did animation-composition: accumulate; it’s slightly different behavior. Rather than appending to the list of space-separated values, it increments the values if it finds a match.

.thing {
  transform: translateX(50px) rotate(20deg);
  animation: doAnimation 5s infinite alternate;
  animation-composition: accumulate;
}

@keyframes doAnimation {
  from {
    transform: translateX(0);
    /* starts as if: 
       transform: translateX(50px) rotate(20deg); */
  }
  to {
    transform: translateX(100px);
    /* ends as if:
      transform: translateX(150px) rotate(20deg) */
  }
}

It’s not just transform that behave this way, I just found it a useful way to grok it. (Which is also why I had space-separated filter on the mind.) For instance, if a @keyframes was adjusting opacity and we used add or accumulate, it would only ever increase an opacity value.

.thing {
  opacity: .5;
  
  transform: translateX(50px) rotate(20deg);
  animation: doAnimation 2s infinite alternate;
  animation-composition: add;
}

@keyframes doAnimation {
  from {
    opacity: 0;
    /* thing would never actually be 0 opacity, it would start at 0.5 and go up */
  }
  to {
    opacity: 1;
  }
}

So that’s that! Understanding how “stacked” transforms works is very interesting to me and I have a feeling will come in useful someday. And I feel the same way about animation-composition. You won’t need it until you need it.

]]>
https://frontendmasters.com/blog/stacked-transforms/feed/ 2 6457
Custom Select (that comes up from the bottom on mobile) https://frontendmasters.com/blog/custom-select-that-comes-up-from-the-bottom-on-mobile/ https://frontendmasters.com/blog/custom-select-that-comes-up-from-the-bottom-on-mobile/#respond Tue, 01 Jul 2025 19:37:38 +0000 https://frontendmasters.com/blog/?p=6293 Custom <select> menus are a thing now, especially because they can be progressively enhanced into. Una has some great examples.

Screenshot of a customizable select menu with flags and country names in a dropdown format.
Demo, which falls back to entirely default styling.

I was recently at CSS Day and got to see Brecht De Ruyte do a whole talk on it. He’s also go a threefour-part series on it (starting here). My brain was full of CSS stuff while there, I had a weird hankering to work on a custom select that combined a bunch of it. I roped Brecht into collabing on my idea.

See, we were on the heals of the whole liquid glass thing from Apple and it seemed fun to make the selects kinda glassy with borders and blur. I also wanted to if animating the select in was possible (and maybe stagger them in?!). Plus, I was reminiscing about the original weird iOS select UI where it had a special UI that came up from the bottom. Is that maybe… better? for thumb-reach? So let’s try that.

The Base

I like Brecht’s snippet that sets the stage nicely:

select {
  appearance: none;
  @supports (appearance: base-select) {
    &,
    &::picker(select) {
      appearance: base-select;
    }
  }
}

That’s saying:

  1. We’re going to wipe out the base styling anyway. Even browsers that don’t support the entire suite of custom styles for selects support styling the basic element itself, just not the “picker” part.
  2. In browsers that support it, we need to set appearance: base-select; to opt-in to the custom styleabtlity, and we need to do it both on the select itself and the picker, which uses this newfangled pseudo element.

Minor aside: it’s interesting that the appearance value is base-select for now. In the hopefully-not-too-distant future, we’ll be opt-in “resetting” not just selects but all the form elements with appearance: base. But I guess that isn’t far enough along and may have been a slightly dangerous breaking change scenario, so it’s isolated to base-select for now. So be it.

The Glassy Look

We’ve got the ability now to style the select directly and a good amount of lienency to style it however we want. Here, a blurry background is applied and the dropdown arrow is applied with a background SVG. (This is Brecht’s cool idea and implementation, as a reminder.)

select {
  display: flex;
  justify-content: space-between;
  min-width: 300px;
  align-items: center;
  color: white;
  padding-block: 10px;
  padding-inline: 10px 30px;
  border: 0;
  border-radius: 5px;
  cursor: pointer;
  font-weight: 700;
  backdrop-filter: blur(5px);
  background: oklch(0.4764 0.2094 259.13 / 0.3)
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke-width='1.5' stroke='%23FFF' class='size-6'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='m19.5 8.25-7.5 7.5-7.5-7.5' /%3E%3C/svg%3E%0A")
    right 10px center / 20px no-repeat;
}

Even in Firefox, which doesn’t support appearance: base-select, we’ve got the look we’re after:

Custom select menu with a blurred background, showing the option 'One' and a dropdown arrow.

We have no ability to style the picker in Firefox or Safari (yet!) but that’s totally fine. We just get the default experience:

A custom select menu with a blurred glassy background featuring options: 'One' (selected), 'Two', 'Three', and 'Four'.

Our goal is to change up this experience on small screens, so it’s a little unfortunate this stuff isn’t in iOS yet (it is in Android!) but again, we just get the default experience which is fine:

A close-up view of a customizable select dropdown menu on an iPhone 15, displaying options 'One', 'Two', 'Three', and 'Four' with a blue gradient background.

The Picker Icon

We can start playing with, in a progressive enhancement friendly way, styling the custom “picker” now. Let’s do the icon first.

select {
  ...

  @supports (appearance: base-select) {
    background: oklch(0.4764 0.2094 259.13 / 0.3);

    &:focus,
    &:hover {
      background-color: oklch(0.4764 0.2094 259.13 / 0.6);
    }
    
    &::picker-icon {
      content: "";
      width: 20px;
      height: 20px;
      background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke-width='1.5' stroke='%23FFF' class='size-6'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='m19.5 8.25-7.5 7.5-7.5-7.5' /%3E%3C/svg%3E%0A");
      transition: rotate 0.2s ease-out;
    }

    &:open::picker-icon {
      rotate: 180deg;
    }
  }
}

When the browser supports it, we’ll rip off the SVG background we were using for the dropdown arrow and apply it as the ::picker-icon instead. That alone isn’t terribly useful, but now because we can target it individually, we can animate a rotation on it. That’s nice.

The Picker

Styling the part that opens up when you active a select we’re calling the “picker”, and this is the part that’s completely new to be able to style. You get your hands on it with the somewhat unusual select::picker(select) selector. You have to put select in the pseudo function thing — it’s the only valid value. For now? Maybe it’s because in the future they’ll want to use ::picker for date inputs or the like? Not sure but whatever.

select {
  ... 
 
  @supports (appearance: base-select) {
    ...

    &::picker(select) {

    }
  }
}

We don’t really need much styling of the picker itself. That is, we want to remove the base styling by making the background transparent. The option elements themselves will have the look.

This is where we’re going to do some interesting positioning, though. The way the ::picker positions itself next to the select is: anchor positioning! Of course it is, might as well use the layout primitives baked into the browser. It does feel weird/interesting to see at work though, as we need to be aware of it to change it. We’re going to wait for small screens, then attach the picker to the bottom of the screen.

select {
  ... 
 
  @supports (appearance: base-select) {
    ...

    &::picker(select) {
      background: transparent;

      @media (width < 400px) {
        position-anchor: --html;
        bottom: 0;
        width: 100%;
      }
    }

    option {
      backdrop-filter: blur(12px);
    }
  }
}

Again the theory there is small screens are often phones and we’re moving the picker down to make it more thumb-reachable. It’s an assumption. Maybe we should be thinking in terms of @media (pointer: coarse) or something, but I’ll leave that to you, we’re just playing.

Animating

I’d rate this stuff as decently complicated to animate. Here’s some reasons:

  • The Shadow Root is at play here, making using DevTools to poke around in there while you’re working is a little extra cumbersome.
  • The ::picker is a display none to block change when it becomes visible, which means to animate it you need to remember transition-behavior: allow-discrete and how all that works.
  • We’re also going to need @starting-style to get incoming animations, which can be repetitive. Plus some bonus staggering.
  • We’ve got an :open state to mix in, @media queries to mix in, a :checked state for the options with a ::checkmark, and other pseudos.

All together, it just feels like a lot. It’s a lot of different nested state spread out. Even trying to organize it as nicely as possible, it’s hard to keep straight. The nesting is handy, but you can’t nest quite everything. Like the :open state is on the select, so you can’t style the ::picker and then the open state within it, which would be handy for @starting-style, because you really need to write select:open::picker(select) not select::picker(select):open It’s fine it’s just a little bah humbug.

Lemme just put the basics for the stagged in/out animations for the option elements here for a taste:

select {
  ... 
 
  @supports (appearance: base-select) {
    ...

    option {
      ... 

      transition-property: opacity, scale;
      transition-duration: 0.2s;
      transition-delay: calc((sibling-count() - sibling-index()) * 100ms);
      scale: 0.25;
      opacity: 0;
    }

    &:open {
      option {
        scale: 1;
        opacity: 1;
        transition-delay: calc(sibling-index() * 100ms);

        @starting-style {
          scale: 0.25;
          opacity: 0;
        }
      }
    }
  }
}

See above it was necessary to repeat the option selector. Not a huge deal, but you usually expect to avoid that with nesting. Plus the @starting-style thing can feel repetitive, but that’s offering the possibility of different in-and-out styling so it’s ultimately a good thing.

The staggered / scale / fade-in thing feels nice to me, and particularly nice when they skoosh up from the bottom anchored position.

Demo

There’s a bunch more CSS tucked in there to make it all happen, so you might as well have the whole thing here:

]]>
https://frontendmasters.com/blog/custom-select-that-comes-up-from-the-bottom-on-mobile/feed/ 0 6293
Motion + CSS https://frontendmasters.com/blog/motion-css/ https://frontendmasters.com/blog/motion-css/#respond Thu, 16 Jan 2025 20:18:08 +0000 https://frontendmasters.com/blog/?p=4988

🚀 Motion springs into the world of CSS generation.Generate spring animations with our upgraded, simplified spring() function. With guides for:- @astro.build – @vuejs.org – @react.dev Server ComponentsAnd more!🔗 motion.dev/docs/css

Matt Perry (@citizenofnowhe.re) 2025-01-14T13:24:02.376Z
]]>
https://frontendmasters.com/blog/motion-css/feed/ 0 4988
Easing Wizard https://frontendmasters.com/blog/easing-wizard/ https://frontendmasters.com/blog/easing-wizard/#respond Fri, 10 Jan 2025 21:14:35 +0000 https://frontendmasters.com/blog/?p=4949 I’ve seen other websites for helping you generate cubic-bezier() values in CSS for animations and transitions, but Easing Wizard is the best. Importantly, it helps you with the newer linear() style timings as well, which are more powerful. All the different types, the presets, the customization options, the different styles of previews, the clean design… outstanding work from Matthias Martin.

]]>
https://frontendmasters.com/blog/easing-wizard/feed/ 0 4949
Delays Before @keyframe Animations and Between Iterations https://frontendmasters.com/blog/delays-before-keyframe-animations-and-between-iterations/ https://frontendmasters.com/blog/delays-before-keyframe-animations-and-between-iterations/#respond Tue, 19 Nov 2024 19:16:37 +0000 https://frontendmasters.com/blog/?p=4547 There are no obvious CSS properties or values for making a @keyframe animation wait between iterations, or have a pause before starting. But Nils Riedmann cooked up a clever way to do it using linear() animation timing. I think this is cleaner than faking it with multiple benign keyframe steps. Remember linear is not the same as linear(). The later is a function that can take any number of points and thus create any sort of easing.

]]>
https://frontendmasters.com/blog/delays-before-keyframe-animations-and-between-iterations/feed/ 0 4547
CSS Fan Out with Grid and @property https://frontendmasters.com/blog/css-fan-out-with-grid-and-property/ https://frontendmasters.com/blog/css-fan-out-with-grid-and-property/#comments Wed, 09 Oct 2024 13:27:37 +0000 https://frontendmasters.com/blog/?p=4128 A “fan out” is an expanding animation where a group of items appear one after another, next to each other, as though they were spread out from a stack. There’s usually a subtle bounciness in the reveal.

The effect is customarily achieved by timing and positioning each of the items individually with very specific hard set values. That can be an awful lot of work though. We can make things a bit easier if we let the items’ parent container do this for us. Here’s a result of doing it this way: 

UPDATE: This article has been updated to now include the animation of the grid items’ height, to produce an overall smoother transition effect. The previous version of this article didn’t cover that. 

For HTML, there’s a group of items (plus an empty one — I will explain later why it’s there), bookended by two radio controls to prompt the opening and closing of the items respectively.

<section class="items-container">
  <p class="items"><!--empty--></p>
  <label class="items close">
    Close the messages<input type="radio" name="radio">
  </label>
  <p class="items">Alert from Project X</p>
  <p class="items">&#x1F429; Willow's appointment at <i>Scrubby's</i></p>
  <p class="items">Message from (-_-)</p>
  <p class="items">NYT Feed: <u>Weather In... (Read more)</u></p>
  <p class="items">6 more items to check in your vacation list!</p>
  <label class="items open">
    Show the messages<input type="radio" name="radio">
  </label>
</section>

We need a grid container for this to work, so let’s turn the <section>, the items’ container element, into one. You could use a list or anything you feel is semantically appropriate.

.items-container {
  display: grid; 
}

Now create an Integer CSS custom property with a value same as the number of items inside the container (including the open and close controls, and the empty item). This is key to implement the revealing and hiding of the items, sequentially, from within the grid container’s style rule.

Also, register another CSS custom property of data type length that’ll be used to animate each item’s height during the opening and closing of the control, for a smoother execution of the overall action. 

@property --int {
  syntax: "<integer>";
  inherits: false;
  initial-value: 7;
}

@property --hgt {
  syntax: "<length>";
  inherits: false;
  initial-value: 0px;
}

Use the now created --int and --hgt properties to add that many grid rows of zero height in the grid container. 

.items-container {
  display: grid; 
  grid-template-rows: repeat(calc(var(--int)), var(--hgt));  
}

When directly adding --int to repeat() it was producing a blotchy animation in Safari for me, so I fed it through calc() and the animation executed well (we’ll look into the animation in a moment). However, calc() computation kept leaving out one item in the iteration, because of how it computed the value 0. Hence, I added the empty item to compensate the exclusion. 

If Safari did not give me a blotchy result, I would’ve not needed an empty item, --int’s initial-value would’ve been 6, and grid-template-rows’s value would’ve been just repeat(var(--int), 0px). In fact, with this set up, I got good animation results both in Firefox and Chrome. 

In the end though, I went with the one that uses calc(), which provided the desired result in all the major browsers. 

Let’s get to animation now:

@keyframes open { to { --int: 0; --hgt:60px;} }
@keyframes close { to { --int: 6; --hgt:0px;} } 
.item-container {
  display: grid; 
  grid-template-rows: repeat(calc(var(--int)), var(--hgt)); 
  &:has(.open :checked) {
  /* open action */
    animation: open .3s ease-in-out forwards;
    .open { display: none; }
  }
  &:has(.close :checked) {
  /* close action */
    --int: 0;
    --hgt: 60px;
    animation: close .3s ease-in-out forwards;
  }
}

When the input is in the checked state, the open keyframe animation is executed, and the control itself is hidden with display: none

The open class changes --int’s value from its initial-value, 7, to the one set within the @keyframes rule (0), over a set period (.3s). This decrement removes the zero height from each of the grid row, one by one, thus sequentially revealing all the items in .3s or 300ms. Simultaneously, --hgt’s value is increased to 60px from its initial 0px value. This expands each item’s height as it appears on the screen. 

When the input to hide all the items is in the checked state, the close keyframe animation is executed, setting --int’s value to 0 and --hgt’s value to 60px.

The close class changes the now 0 value of --int to the value declared in its rule: 7. This increment sets a zero height to each of the grid row, one by one, thus sequentially hiding all the items. Simultaneously, --hgt’s value is decreased to 0px. This shrinks each item’s height as it disappears from the screen. 

To perform the close action, instead of making a unique close animation, I tried using the open animation with animation-direction: reverse. Unfortunately, the result was jerky. So I kept unique animations for the open and close actions separately.

Additionally, to polish the UI, I’m adding transition animations to the row gaps and text colors as well. The row gaps set cubic-bezier() animation timing function to create a low-key springy effect. 

.scroll-container {
  display: grid; 
  grid-template-rows: repeat(calc(var(--int)), 0px); /* serves the open and close actions */
  transition: row-gap .3s .1s cubic-bezier(.8, .5, .2, 1.4);
  &:has(.open :checked) {
    /* open action */
    animation: open .3s ease-in-out forwards;
    .open { display: none; }
    /* styling */
    row-gap: 10px;
    .items { color: rgb(113 124 158); transition: color .3s .1s;}
    .close { color: black }
  }
  &:has(.close :checked) {
    /* close action */
    --int: 0;
    animation: close .3s ease-in-out forwards;
    /* styling */
    row-gap: 0;
    .items { color: transparent; transition: color .2s;}
  }
}

When expanded, the row gaps go up to 10px and the text color comes in. When shrinking, the row gaps go down to 0 and the text color fades out to transparent. With that, the example is complete! Here’s the Pen once more:

Note: You can try this method with any grid compositions — rows, columns, or both.

Further Reading

]]>
https://frontendmasters.com/blog/css-fan-out-with-grid-and-property/feed/ 3 4128