Solving non animatable properties with CSS Custom Properties

This article explains that although a near-complete solution was achieved, subtle rendering issues remain. The real solution isn’t tweaks, but rethinking the design from the ground up.
Isaac Pavon
Isaac Pavón

Introduction 

Nowadays, web design and coding are more complicated compared than in the past years. Marketing is more competitive, and companies are constantly challenged to offer better and more attractive designs to the clients, and that consequently makes web programming more difficult. New designs with more organic shapes and fancy animations are techniques to attract the users’ attention and try to avoid that they leave to another website. 

Web developers have to adapt to that goal and need to be up-to-date with the most recent coding techniques that are in constant evolution, like using CSS custom properties which have saved our lives in our last project. 

 

The problem 

Having this previous context in mind, we were asked to “rebrush” an old website. Until here, nothing new or nothing that we wouldn’t be prepared for. However, the external agency provided the new designs that the client approved. What a surprise when we saw a lot of organic shapes that we would need to implement. Specially one complicated one for the teasers. 

Initial Design

Additionally, the button grows when is hovered making the inverted corner bigger. 

This was an unexpected challenge. We used to have complex shapes in the past, but now, we have to animate them. 

 

Solution in the past 

The old approach that we used was trying to make the shape part of the CTA button. The button will be positioned as absolute at the bottom right corner of the teaser and then we can apply a padding to the CTA wrapper that will grow then the CTA is hovered. The CTA wrapper would have a background colour (i.e. white in this case) to achieve the effect. Then we can add a border-radius to the top left of the wrapper and for the inverted corners we could use two pseudo elements with border-radius 50% for the top left and bottom right corner and apply a white box-shadow. 

I think an image could explain it better: 

Using pseudoelements

For this example, I’ve changed the color of the box-shadow to red instead of white. As you can see, we have the CTA wrapper with white background and the two box shadows that mimics the rounded shape that we are looking for. 

.cta__wrapper {
  position: absolute;
  bottom: 0;
  right: 0;
  z-index: 1;
  background-color: var(--bg-color);
  padding: 16px 0 0 16px;
  border-radius: 16px 0 0 0;
  transition: padding var(--timing, 800ms);
  will-change: padding;
  transform: translateZ(0);

  // Other curves
  &:before,
  &:after {
    position: absolute;
    content: "";
    width: 32px;
    height: 32px;
    border-radius: 50% 0 50% 0;
    box-shadow: 6px 6px 0 0 var(--bg-color);
  }

  &:before {
    bottom: 0;
    right: 100%;
  }

  &:after {
    bottom: 100%;
    right: 0;
  }
}

You can see the example in this codepen: https://codepen.io/Isaac-pavon/pen/PwPqJbG 

This approach was reliable, robust and stable. Why couldn’t we use it for our project? Because we needed to know beforehand the background color of the teaser container. The two inverted corners and the cta wrapper needed to apply the same background color of the teaser container to make the illusion work. Of course, we could set it as a variable that parses from the teaser container to the teaser itself, but we had plenty of situations where we couldn’t control it because there are a lot of different variants and places where the teasers would be used. 

Therefore, we would need a solution that could make the thing easier without being dependent on the background colour. 

  

Solution with Mask and Custom Properties 

Luckily, CSS has evolved a lot and offers us plenty of new properties and functionalities that we can use to achieve the same goals than before in a more performant way, with less restrictions and more flexibility. 

We could use clip-path or mask to face the challenge. Which one should we use? 

Let’s see pros and cons of each one: 

Property Pros Cons
Clip-path

- Easy to use- Simple syntax with basic shapes (circle, polygon, etc.)

- Good browser support

- Supports percentages and relative units 

- Partially animatable for basic shapes, otherwise no

- For complex shapes with circles doesn’t work so well Hard to animate (only some basic shapes like inset, circle can be animated)

- No partial transparency (acts as a hard cut-out)

- Doesn't support image-based clipping 

Mask

- Allows partial transparency and gradients

- Can use images (mask-image)

- Very flexible for advanced effects

- Easier to animate in some cases 

- More complex syntax

- Limited browser support (some cases require vendor prefixes)

- Slightly heavier on performance (especially with image masks or gradients) 

 

Unfortunately, we couldn't use clip-path because our shape is quite complex to achieve, we had three circles and two of them were inverted. Despite the availability of very good online tools to design the desired shape, they didn’t offer what we were looking for. 

Therefore, we tried then to use mask a possible solution. I found a quite interesting tool that could help us to create the shape. This tool is amazing, and I strongly recommend it, however it has its own limitations for our case. The shape is defined in absolute units, and our teaser could grow or stretch depending on the container or the image size. Unfortunately, it didn't work for us. 

I asked to AI how we could solve this, and the AI was trapped in a loop offering solutions that didn’t fit with our problem, using SVG images, try to create complex clip-paths (totally broken by the way…). AI wasn’t going to help us at all... 

Then I started to look for other old school solutions that could work for us. Luckily, I found an article in Stackoverflow that completely could satisfy our needs.

Another article

Yes! It is quite similar, and the solution offered by Temani Afif was exquisite, elegant and performant: 

img {
  --r: 30px; /* radius */
  /* control the cutout */
  --x: 80px;
  --y: 80px;
  /**/
  width: 250px;
  border-radius: var(--r);
  --g: /var(--r) var(--r) no-repeat exclude radial-gradient(100% 100% at 0 0, #0000
        100%, #000 calc(100% + 1px));
  mask: calc(100% - var(--x)) 100% var(--g), 100% calc(100% - var(--y)) var(--g),
    radial-gradient(100% 100% at 100% 100%, #0000 100%, #000 calc(100% + 1px))
      calc(100% - var(--x) + var(--r)) calc(100% - var(--y) + var(--r)) /
      var(--r) var(--r) no-repeat,
    conic-gradient(
      from 90deg at right var(--x) bottom var(--y),
      #0000 25%,
      #000 0
    );
}

We could simplify it removing the --g variable and integrating it directly to the mask formula. 

mask:  
  /* Layer 1: Bottom left forner (exclude) */ calc(100% - var(--x)) 100% /
    var(--r) var(--r) no-repeat exclude
    radial-gradient(100% 100% at 0 0, #0000 100%, #000 calc(100% + 1px)),
  /* Layer 2: Top right corner (exclude) */ 100% calc(100% - var(--y)) /
    var(--r) var(--r) no-repeat exclude
    radial-gradient(100% 100% at 0 0, #0000 100%, #000 calc(100% + 1px)),
  /* Layer 3: Top left corner (include) */
    radial-gradient(100% 100% at 100% 100%, #0000 100%, #000 calc(100% + 1px))
    calc(100% - var(--x) + var(--r)) calc(100% - var(--y) + var(--r)) / var(--r)
    var(--r) no-repeat,
  /* Layer 4: Conic for the irregular shape at the bottom right (include) */
    conic-gradient(
      from 90deg at right var(--x) bottom var(--y),
      #0000 25%,
      #000 0
    );

Awesome, it worked like a charm. As the CTA was just an arrow with a prefixed size, we could adjust the variables for our desired shape (--x and --y). Marvelous, we achieved our goal... 

Or not? 

Unfortunately, now we had to animate the mask when the CTA is hovered. Then we can just add a transition to the mask property and setting the new variables on hover state. 

transition: mask 400ms;

&:has(.cta:hover) {
  .media__image {
    --x: 62px;
    --y: 62px;
  }
}

And… it didn’t work. The mask was resized but without any animation or transition. Too jumpy, this was not going to be accepted. 

Maybe I could forget about variables and write the initial size in the origin mask and then the end size in the target mask. It worked but with a lot of artifacts. Modern browsers still have issues managing complex mask, especially when there are a lot of calculations inside. 

How disappointing! We were almost there… 

The problem with this transition with artifacts is caused because the browser can’t interpolate the animation between the original values of the variables used in the mask. If we could make them be interpolables, this issue would be solved. 

The natural tendency would be adding the variables to the transition property,  

transition: mask 400ms, --x 400ms, --y 400ms, --r 400ms;

However, this won’t work because the variables are not animatable or interpolable per se. 

Now is when CSS custom properties come to help. 

 

CSS Custom properties 

Custom properties were part of CSS Houdini set of APIs that were delivered in 2023 in a complete and stable version (although it was already announced in 2021). It allows developers to explicitly define CSS custom properties, allowing for property type checking and constraining, setting default values, and defining whether a custom property can inherit values or not. 

The @property rule represents a custom property registration directly in a stylesheet without having to run any JavaScript. Valid @property rules result in a registered custom property, which is similar to calling registerProperty() with equivalent parameters. 

@property --rotation { 
  syntax: "<angle>"; 
  inherits: false; 
  initial-value: 45deg; 
}

Here you can find another example of how CSS custom properties can be used for animations (angle in this case):

And the most important thing of all of this, Custom Properties allow to interpolate between values, that was what we were looking for! 

We could define our variables as custom properties: 

// Cutout horizontal distance.

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

// Cutout vertical distance.
@property --y {
  syntax: "<length>";
  initial-value: 52px;
  inherits: false;
}

// Radius custom property.
@property --r {
  syntax: "<length>";
  initial-value: 16px;
  inherits: false;
}

.media__image {
  width: 100%;
  height: 100%;
  max-width: 100%;
  object-fit: cover;
  object-position: center center;
  border-radius: 16px 16px 0 16px;
  overflow: hidden;

  // Radius.
  --r: 16px;

  // Dimensions of the cutout.
  --x: 52px;
  --y: 52px;

  mask:  
    /* Layer 1: Bottom left forner (exclude) */ calc(100% - var(--x))
      100% / var(--r) var(--r) no-repeat exclude
      radial-gradient(100% 100% at 0 0, #0000 100%, #000 calc(100% + 1px)),
    /* Layer 2: Top right corner (exclude) */ 100% calc(100% - var(--y)) /
      var(--r) var(--r) no-repeat exclude
      radial-gradient(100% 100% at 0 0, #0000 100%, #000 calc(100% + 1px)),
    /* Layer 3: Top left corner (include) */
      radial-gradient(100% 100% at 100% 100%, #0000 100%, #000 calc(100% + 1px))
      calc(100% - var(--x) + var(--r)) calc(100% - var(--y) + var(--r)) /
      var(--r) var(--r) no-repeat,
    /* Layer 4: Conic for the irregular shape at the bottom right (include) */
      conic-gradient(
        from 90deg at right var(--x) bottom var(--y),
        #0000 25%,
        #000 0
      );

  // Make transitions for variables too.
  transition: mask 400ms, --x 400ms, --y 400ms, --r 400ms;
  will-change: mask, --x, --y, --r;
  transform: translateZ(0);
}

&:has(.cta:hover) {
  .media__image {
    --x: 62px;
    --y: 62px;
  }
}

We set the variables again in normal status to not have problems with the transitions, and voilá! It’s working. 

However, not all the news could be good. Unfortunately, we have two small problems: 

  1. Custom properties are not supported by safari <17. We could leave the jumpy hover state for old safari browsers and eventually the problem would be solved itself. I think it is a minor issue that we could live with. 
  2. Chrome browsers still have some minor issues transitioning with masks because of the render engine. The calculations with subpixel units make to appear during the transition some minor edges between the different shapes that compounds the mask. It could be fixed adding image-rendering: pixelated (because it makes lighter the subpixel calculations). However, this makes the image quality got worse, so, for the moment, we can’t fix this properly. It is something that we have to live with it too until chrome improve its render engine. In any case, the artifacts are quite small and almost unnoticeable. 

 You can see the results in this codepen.

In any case, the results should be evaluated by the design agency and the client, we couldn’t decide which solution should be applied on our own. Probably the issues in the new solution would be solved eventually, but, given the current requirements, probably we couldn’t be used as it is and it would require going back to the traditional one and trying to set up as much as possible the background colour variables. 

Despite these drawbacks, we have learned how useful are custom properties and how much potential they have to face future challenges. 

 

Conclusions 

This challenge led us to explore the evolving capabilities of CSS in depth. What initially seemed like a small design requirement — an animated inverted corner — uncovered the limitations of traditional CSS techniques and pushed us to adopt cutting-edge solutions. 

In the past, we relied on pseudo-elements, box shadows, and absolute positioning to simulate complex shapes. These techniques worked, but they were fragile, rigid, and heavily dependent on background colours. They also made scaling and reuse difficult across multiple layouts or themes. 

The introduction of mask and the ability to combine it with @property-registered custom properties opened a new world of possibilities. It allowed us to:  

  • Decouple layout and appearance, eliminating the dependency on inherited background colours 
  • Create complex shapes with precise control using native CSS — no images, no SVGs, no extra DOM elements 
  • Animate previously non-animatable properties smoothly using just CSS 
  • Improve scalability across different container sizes through relative units and CSS variables 

Of course, not everything worked perfectly. We encountered rendering glitches in Chrome and limited support in older versions of Safari:

These drawbacks, while minor in most cases, could still impact the feasibility of deploying this solution in production without fallback strategies. We'll revisit this topic in the near future with a second part of this article.

In any case, we couldn’t make the final decision on our own. The design agency and the client needed to evaluate whether the benefits outweighed the minor rendering issues. In the end, we might need to return to the traditional solution and manage background colour propagation more carefully. 

But despite that, this experience proved how far CSS has come—and how far it can go. With tools like @property, Houdini, and modern masking techniques, CSS is no longer just a styling language. It’s becoming a true engine for interactive UI logic and advanced animations. We’re optimistic about the future, and excited to keep pushing the boundaries of what’s possible—natively, cleanly, and creatively. 

Unser Experte

Isaac Pavon

Isaac Pavón

Frontend Developer

Isaac Pavón is part of the Cocomore Team as a Drupal FrontEnd Developer since September 2018. Before, he developed other projects as Freelance Frontend for more than two years.

Haben Sie Fragen oder Input? Kontaktieren Sie unsere Expert:innen!

E-Mail senden!