This blog post includes moving SVGs! If you are sensitive to motion, please
do not read this article. The landing page, however, does respect your
prefers-reduced-motion
setting.
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:
- Very hard: Go through the referenced element and update all values (
x
,y
attributes, any absolute positioned value in thed
attributes, maybe even values oftranslate
?) by the amount of thex
andy
attribute. - 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 theid
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.