This blog post explores how to fix SVG animations of elements referenced with <use>.

In an earlier blog post I’ve described how I created the background on my landing page. One of the caveats was, that the animation is not working on the Safari (or any WebKit) browser.

The Issue

SVGs have a nice feature, that allows you to reference an element, such that you can re-use it multiple times. That can reduce the total size of the drastically

  • depending on the number of elements that are referenced.

However, when it comes to animation, Safari behaves different to other browsers. Usually, the elements that you are referencing via a <use> element can be animated just like any other SVG element. But on Safari, the animation will not start. There is also an open bug ticket for this since (at least) 2017.

You can see the difference in this StackOverflow post. In case you don’t have an Apple device, I highly suggest you install a WebKit based browser. Personally I use Gnome Web.

The Fix

When WebKit refuses to load animations, then we must resolve the referenced elements ourselves. However, as mentioned already, if we do this in the file directly, the resulting file would be very bloated.

Instead, I suggest using JavaScript for this job. So let’s get started.

Finding the references

Depending on the use-case, this can be quite easy. Take this SVG for example:

<svg>
  <defs>
    <g id="box">
      <rect width="10" height="10" stroke="hotpink" />
    </g>
  </defs>

  <use href="#box" />
</svg>

The <use> element is referencing the <g> with ID box. In JavaScript this can be emulated quite as easily with

const referencedElement = document.getElementById("box");

In theory, however, the <use> element could use any valid URL as href value. For my use-case I know that all <use> elements reference SVG elements in the same DOM. So I’m not going to bother with remote SVG files or similar.

My resolution method is therefore quite simple: Just read the value of the href attribute and resolve the string after the # as ID on the DOM.

Mapping the Content

Resolving the referenced elements is only one part of the puzzle, however. We also need to properly wrap the elements that are being referenced. The reason for this is that elements being referenced are sort-of rendered in their own coordinate system that can be offset by values on the <use> element. Here a simple example:

<svg>
  <defs>
    <g id="box">
      <rect x="10" width="10" height="10" stroke="hotpink" />
    </g>
  </defs>

  <use x="5" href="#box" />
</svg>

The <rect> defines an x-coordinate of 10. The <use> has an x-coordinate of 5. In total, the <rect> will therefore be rendered at x=15.

To solve this issue, we have two possibilities:

  1. Very hard: Go through the referenced element and update all values (x, y attributes, any absolute positioned value in the d attributes, maybe even values of translate?) by the amount of the x and y attribute.
  2. Very easy: Wrap the referenced element with a new <svg> element. Having nested <svg> elements is totally valid and has the same effect: The elements creates a new context for coordinates to originate from.

In our example, this means we have to convert it to this SVG:

<svg>
  <svg x="5" >
    <g id="box">
      <rect x="10" width="10" height="10" stroke="hotpink" />
    </g>
  </svg>
</svg>

NOTE: If multiple <use> elements reference the same ID, then after inlining the ID(s) won’t be unique anymore! But we also can’t remove the id attribute from the elements, because they might be used for styling or in code. Again, for my use-case this was fine, but you might have to consider the consequences.

To make sure I don’t overlook some property, I copy over all attributes of the <use> element to the newly created <svg> element:

const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
for (const { nodeName, nodeValue } of [...svgUseElement.attributes]) {
  if (nodeValue != null && nodeName !== "href") {
    svg.setAttribute(nodeName, nodeValue);
  }
}

Let’s see how the SVG look after the mapping:

Before:

After:

This does look really good! Both squares are rendered identically. But not so fast. What if we animate it now. Let’s say with a small left to right animation.

<svg>
  <svg x="5" >
    <g id="box">
      <rect x="10" width="10" height="10" stroke="hotpink" />
    </g>
  </svg>

  <!-- Add CSS animation on the #box element -->
  <style>
    #box {
      animation: toLeft 1s infinite alternate;
    }

    @keyframes toLeft {
      100% {
        translate: -15px 0;
      }
    }
  </style>
</svg>

The result in action:

The box is cut off on the left! Luckily, this is an easy fix. The <svg> element has an overflow: hidden per default. So we need to override it with overflow: visible.

And here is the result:

That also fixes the cut off stroke at the top. Nice!

The Final Result

Now that we know how to update the SVG, all we have to to is writing a recursive mapping function to update the SVG. I’m not going to keep you at the edge of your seat any longer, so here is the code that I’m using to fix the SVG on the landing page:

/**
 * This is the recursive stepping function. You give it the root node and a
 * mapper function. The returned value is a cloned, mapped DOM node.
 */
function transform(node, fn) {
  const next = Object.assign(
    function (childNode) {
      return fn(childNode, next);
    },
    {
      // Utility to step through all children of a node recursively.
      forAll(node) {
        const transformed = node.cloneNode();
        for (const child of node.childNodes) {
          transformed.appendChild(next(child));
        }
        return transformed;
      },
    }
  );

  return fn(node, next);
}

// This the mapping function I'm using to update all `<use>` elements inside an
// SVG.
function resolveSvgUse(node, next) {
  if (!(node instanceof SVGUseElement)) {
    // Nothing to map, so just step through all children recursively
    return next.forAll(node);
  }
  const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
  for (const { nodeName, nodeValue } of [...node.attributes]) {
    if (nodeValue != null && nodeName !== "href") {
      svg.setAttribute(nodeName, nodeValue);
    }
  }

  svg.style.overflow = 'visible';

  const href = node.getAttribute("href");
  if (!href?.startsWith("#")) {
    // We do not support non-ID based href
    return svg;
  }
  const reffed = document.getElementById(href.slice(1));
  if (!reffed) {
    // Not found
    return svg;
  }

  svg.appendChild(next(reffed));

  return svg;
}

// Get the root SVG to update/replace
const waves = document.getElementById('waves');

if (!(waves instanceof SVGSVGElement)) {
  throw new TypeError('Waves are no SVG')
}

waves.replaceWith(transform(waves, resolveSvgUse));

NOTE: the code above is definitely not perfect and will not cover all edge cases. But for me it worked quite well, so I wanted to share it.