Offloading SVG transitions to the GPU using layers

Previously I wrote about leveraging steps() in CSS animations to enhance SVG transitions performance by essentially "lowering the framerate" of the transition. This worked well, but ultimately it wasn't enough, because CSS fill isn't GPU accellerated. In order to get silky smooth fading between two complex SVGs, I needed to make sure work was being offloaded to the GPU.

In modern browsers (although some filter properties are now supported) basically only opacity and transform are GPU accelerated. So we can transition between multiple layers using opacity.

Here's the basic idea. First we have two SVGs.

<div class="svg-fader-container">
  <!-- Bottom SVG Layer -->
  <svg
    class="svg-layer bottom"
    width="300"
    height="300"
    viewBox="0 0 100 100"
    xmlns="http://www.w3.org/2000/svg"
  >
    <defs>
      <pattern
        id="circle-pattern-bottom"
        width="20"
        height="20"
        patternUnits="userSpaceOnUse"
      >
        <circle cx="10" cy="10" r="8" fill="mediumseagreen" />
      </pattern>
    </defs>
    <rect width="100" height="100" fill="url(#circle-pattern-bottom)" />
  </svg>

  <!-- Top SVG Layer -->
  <svg
    class="svg-layer top"
    width="300"
    height="300"
    viewBox="0 0 100 100"
    xmlns="http://www.w3.org/2000/svg"
  >
    <defs>
      <pattern
        id="circle-pattern-top"
        width="20"
        height="20"
        patternUnits="userSpaceOnUse"
      >
        <circle cx="10" cy="10" r="8" fill="darkorange" />
      </pattern>
    </defs>
    <rect width="100" height="100" fill="url(#circle-pattern-top)" />
  </svg>
</div>

Then we layer the SVGs on top of each other using CSS and animate the top layer's opacity.

.svg-fader-container {
  position: relative;
  width: 300px;
  height: 300px;
  border: 1px solid #eee;
}

.svg-layer {
  position: absolute;
  top: 0;
  left: 0;
}

.svg-layer.bottom {
  z-index: 1;
}

.svg-layer.top {
  z-index: 2;
  animation: fade-top-svg 4s infinite alternate ease-in-out;
}

@keyframes fade-top-svg {
  0% {
    opacity: 1;
  }
  100% {
    opacity: 0;
  }
}

And there you have it! A GPU accelerated transition between two SVGs. Here's a live version.

You could even use steps() to reduce the frequency of opacity changes. Or else tie the opacity to a throttled scroll position or mouse position to create a more interactive experience.

For additional reading check out this excellent article from Smashing Magazine called CSS GPU Animation: Doing It Right

Anyway that's it. Have fun!

ps. if your SVGs are really huge and complex (like mine were), you can even dynamically render them to a canvas element first at the correct DPI, but that's a subject for another time.

Using steps() for performant CSS transitions

I've used a lot of CSS transitions over the years. Shifting between values in a smooth fashion is pretty easy with a simple one-liner.

transition: background-color 2s;

But what if you have a lot of transitions? What if you have too many transitions? Your browser tries to run them as fast as your monitor's refresh rate allows (60 times per second, or more). This can be pretty taxing.

Recently I ran into this problem transitioning many elements of a large SVG with a bunch of complex shapes. With all the elements transitioning all at once, the page scrolling started to stutter quite noticeably.

Here's how I solved it. Instead of a transition, I used CSS animations, with steps() to effectively "bring down the frame rate" and stop the browser from doing too much work.

@keyframes color-change {
  100% {
    fill: rgb(12, 169, 210);
  }
}

.do-transition {
  animation-name: color-change;
  animation-duration: 5s;
  animation-iteration-count: infinite;
  animation-timing-function: steps(10);
}

Or you can use the shorthand.

.do-transition {
  animation: color-change 5s infinite steps(10);
}

Just add the do-transition class to the element you want to animate. Adjust the animation-duration and the steps() count to get a balance between smoothness and performance.

const polygon = svg.querySelector("polygon");
polygon.classList.add("do-transition");

Here's a little demo with a simple SVG. I've kept the steps() low to make the effect more noticeable.

That's the basic idea anyway. I hope it helps if you run into a similar problem.

(Bonus tip: Use animation-delay to stagger the animations if you have a lot of elements transitioning at once so they don't all paint on the screen at once on each step())

Happy coding! 🚀