Animate a hole mask on SVG: can't be hard, right?
Tags: webdev, css, animation, react
Published: 2023-04-23 22:42:39

Some history: 6 years ago a new fresh design was being implemented on a fresh React codebase. At that time people working on the project chose to create a couple of loading animations with the help of Lottie, and some other animations with React Spring.

I joined the project a few years later. Two years ago I refactored React Spring away as it was used for simple on/off transitions which you can implement with a couple of lines of CSS. This resulted to far less code than the minified 50 kB of JS that `react-spring` is.

Now I finally got my hands dirty with eliminating Lottie which is 290 kB of minified JS, and also required about 40 kB JSON for the loading animation that was implemented. Yes, 330 kB of JS code and that doesn't even include the React component and it's related CSS. All that for a loading animation.

Note that the sizes above are minified, not compressed: I rather think about the code browser needs to parse and execute. Also: HTML and CSS are cheap to parse while JavaScript is heavy.

## The animation

{% codepen https://codepen.io/Merri/pen/BaqpXPw %}

I'm not going ultra deep into the implementation details in this article. However here is the list of requirements:

- Horizontally moving looping animation
- Circle moves vertically up and down between lines
- Circle has a hole

That last condition is what makes things very tricky! You can see the background through the hole, it is moving, but remains horizontally in-place.

One would think that as there are masks you could just easily use them. However: especially Chrome is awful when animating any masks! Here is a summary of my research:

| Browser | SVG Mask | CSS Mask | SVG parent | CSS parent | Notes |
| :-- | :-- | :-- | :-- | :-- | :-- |
| Firefox | Good! | Good! | Bad | Good! | Gecko seems the most reliable. |
| Chrome | Awful | Bad | Rasterized | Good! | Chrome is a mine field when it comes to masks. |
| Safari | - | Good! | Good! | Good! | Safari has slight difficulties keeping SVG and CSS animations in perfect sync. |

Here are brief explanations for each of the fields above.

### SVG Mask

This animation is simple as an idea: add SVG mask and then animate the hole up and down within the SVG mask using translateY in keyframes.

Very smooth in Firefox, but Chrome pretty much dies with this solution and the hole's animation becomes a slideshow.

### CSS Mask

This is the CSS variant of the above idea by animating `mask-position`. This almost gets the job done, but Chrome lags behind with it's mask animation when compared to the SVG animation, making this solution undesirable.

### SVG parent

Since animating the mask is not feasible then what about having a static mask image instead, and animating it's parent up and down? And then revert the movement on the child SVG so that it appears as if it would remain vertically in-place.

Performance is great on Chrome and Safari, but Firefox doesn't perform as well and you end up with choppy vertical flickering. However Chrome also does another thing: when it detects a mask within animated SVG element it appears to rasterize the SVG mask image! This means the circle appears pixelated when not in optimal sizes. Defeats the purpose of vector in a vector image.

### CSS parent

Then the final solution that actually works best: the same idea as with the SVG parent, but moved out of SVG to DOM side instead. This means two extra DOM elements to wrap the SVG image:

1. Container element provides the size of the element and hides excess draw. This is an inline flex element which stretches it's child to full width and height.
2. Mask element has a hole mask with `radial-gradient` and also draws another circle with `radial-gradient` as a background image.
3. Mask element also has `margin: -50%;` and `padding: 50%;` (also: `box-sizing: content-box;`).
4. SVG is within the mask element and set to 100% to take all available space.

## Goals met!

The final implementation for the optimized loading animation is only 1% of the original Lottie + animation JSON size, being at below 3 kB. And most of that size is SVG and CSS, not JavaScript. When compressed and sent over the wire the code will probably be less than 1 kB in total.

As usual it wasn't all that easy to find the "perfect" solution. I don't usually do CSS nor SVG animations so it took me a while to research what works and what doesn't. Also checking the results with multiple browsers on a real page requires some time. I had multiple times when I thought I found a solution only to find out later that nope, can't do that after all.

### Lessons learned

- Masks must remain static non-animated images to perform well.
- Use CSS masks over SVG masks, because Chrome is awful with SVG masks.
- Chrome and Safari have difficulties staying in sync with simultaneous CSS + SVG animations. Chrome seems to lag CSS animations behind SVG while Safari seems to lag SVG animations behind CSS.
- Had least problems with Firefox. Firefox animations were always in sync and only got choppy when hitting Gecko's performance bottleneck.

I hope this article saves you some trouble if you ever have to animate a transparency mask over vector images.