DEV Community

Cover image for Smooth gradient animation using @property
Nico Prat
Nico Prat

Posted on

Smooth gradient animation using @property

I recently stumbled upon a small CSS challenge: we needed a pulsing ring of color, something subtle but noticeable enough to catch the user's eye. At first it sounded like an easy task, but after trying a few different approaches it turned out to be trickier than expected, at least if you want the animation to feel perfectly smooth.

Eventually it became a good opportunity to finally experiment with the new CSS custom property registration feature, so here’s a short post showing what worked and what didn’t.

TL;DR: here's the working solution using radial gradient background and @property 👇

The theory

The @property syntax is an improvement over the basic custom properties we already know, like --size: 20px. The declaration syntax looks like this (MDN docs):

@property --ratio {
  syntax: '<percentage>'; /* allows the browser to interpolate values */
  inherits: false;
  initial-value: 0%;
}
Enter fullscreen mode Exit fullscreen mode

The custom property can then be set and used as usual:


div {
  --ratio: 50%;
  padding-bottom: var(--ratio);
}
Enter fullscreen mode Exit fullscreen mode

And we can animate it as well:

@keyframes ratio {
  from {
    --ratio: 50%;
  }
  to {
    --ratio 100%;
  }
}
Enter fullscreen mode Exit fullscreen mode

In this example it doesn’t provide anything that couldn’t be done otherwise, but its real power comes from allowing animation of only parts of a value. For instance, background-color is animatable, but background-image is not. At least, not without using custom properties as we’ll see in a moment.

The practice

So the goal is to animate a ring that grows from its center. Here are the constraints:

  • it should be smooth (meaning nicely anti-aliased)
  • it should have a consistent thickness throughout the animation

Let's try a few different techniques...

❌ Outline offset

For the first attempt, I used outline-offset. It’s very simple, but the issue is that the value is discrete (not continuous), meaning it animates pixel by pixel instead of subpixel. This is less noticeable on high-DPI displays, though I slowed down the animation here to make the issue more visible. The result doesn’t feel quite as smooth as we’d like.

Here's a GIF if you can't see the CodePen in action:

Animating outline offset

❌ Scale

Then I tried using scale. Still very simple, but the issue is that the thickness grows as well. Yes, I tried to cheat by reducing the border-width value during the animation, but like outline-offset, it’s also discrete, so the width jumps during the animation. What's more, Safari seems to use a rasterised version of the element and the result is very blurry.

Here's a GIF if you can't see the CodePen in action:

Animating scale

✅ Radial gradient

Finally, I tried using a gradient background, and this time everything is smooth! The idea is not to use a real gradient, but to use its API to draw a solid line that moves.

The downside is that the implementation becomes slightly more complex because it requires an additional element on top of it that is larger than the original one in order to draw the gradient inside it (backgrounds can’t overflow their element).

Here's a GIF if you can't see the CodePen in action:

As you can see in the code, the trick is to animate the gradient color stops values through the --pulse-size custom property. Once the correct calculations are in place, it becomes easy to reuse and adapt the code using variables.

.pulse {
  /* original value */
  --pulse-size: calc(100% / var(--ratio));
  /* drawing the circle */
  background: radial-gradient(
    /* shape and positioning */
    closest-side circle at 50%,
    /* starting point */
    transparent 0%,
    /* stop drawing transparent */
    transparent calc(var(--pulse-size) - var(--thickness)),
    /* start drawing colored circle */
    var(--color) calc(var(--pulse-size) - var(--thickness)),
    /* stop drawing the colored circle */
    var(--color) var(--pulse-size),
    /* start drawing transparent again */
    transparent var(--pulse-size),
    /* ending */
    transparent 100%
  );
  /* animating the circle */
  animation: pulse 2s 0.35s infinite ease-out;
}
Enter fullscreen mode Exit fullscreen mode

In CodePen you'll see some - 1px, there're here to prevent Safari from drawing aliasing artefacts.

Now we can simply animate the custom property instead of the whole background gradient (which wouldn’t work anyway):

@keyframes pulse {
  100% {
    --pulse-size: 100%;
  }
}
Enter fullscreen mode Exit fullscreen mode

The original value for --pulse-size can't be in the @keyframes definition as it seems to fail in Firefox at the time of writing, so I set it in the element style directly. Frontend wouldn't be fun without some weird browser quirks here and there, right?

Conclusion

So the solution was to use a technique that:

  • keep the size of the element itself: no scale
  • use continues values to prevent "jumps" in the animation: no border or outline
  • doesn't suffer from weird browser issues (like scale in Safari)
  • keep the document flow intact to get a fluid animation

Would you have come up with another solution?

Top comments (0)