Recently I needed to morph one SVG icon into another, in an effort to provide more visual feedback for end users – ideally without adding complex JavaScript logic (e.g. via GSAP).

I’d started out by picking two simple shapes from iconsvg.xyz:

Here’s what, eventually, I ended up with: (Direct link; view source for the implementation.)

Initially I’d figured using CSS transforms should suffice and would be fairly straightforward – but that quickly (and quite literally) went awry: translate and rotate didn’t suffice because line lengths differ between the original and the target icon, yet applying scale to fix that resulted in a skewed projection. After learning that

I managed to create something a little closer to what I had in mind – but it still looked awkward and relied on magic numbers. This being me, eyeballing it was not an option. Neither was Pythagoras: Figuring out the math required to accurately transform the original shapes proved sufficiently annoying (also with respect to posterity) that eventually I went looking for a different approach – though not before descending down a rabbit hole of arduously refreshing my long neglected understanding of vector matrices.

Thus I arrived at SMIL. I had believed this to be deprecated, but turns out Google have since relented. And indeed, SMIL is about as straightforward as I had imagined: <animate> allows specifying a before and after state with the same attributes used in the actual shapes, the engine takes care of calculating the transition.[1] Thus both shapes are combined within a single <svg> element:

<path d="M3,6 l18,0">
    <animate attributeName="d" from="M3,6 l18,0" to="M6,6 l12,12"
            begin="indefinite" dur="200ms" fill="freeze" />
</path>

(Yes, that from redundancy is slightly irritating.)

In order to avoid excessive redundancies animating <line> attributes (x1/y1 and x2/y2, each of which would have required an <animate> declaration), I manually converted them to <path>s, thus reducing the shape definition to a single d attribute. Where with CSS before I’d used JavaScript to toggle a class name, here I needed to invoke #beginElement on each <animate> element to kick off the animation on demand.

However, that still leaves reversing the animation and providing a fallback for browsers that don’t support SMIL. Both proved similarly straightforward, e.g. using a custom element:

<my-toggle>
    <button type="button">
        <svg >
        <span>toggle</span>
    </button>
</my-toggle>
class MyToggle extends HTMLElement {
    connectedCallback() {
        this.addEventListener("click", this.onToggle);
    }

    onToggle(ev) {
        // kick off icon animation
        let nodes = this.querySelectorAll("animate");
        toArray(nodes).forEach(node => {
            toggle(node, this._swapped);
        });
        this._swapped = true;
    }
}

function toggle(node, swap) {
    if(swap) { // reverse animation
        let from = node.getAttribute("from");
        node.setAttribute("from", node.getAttribute("to"));
        node.setAttribute("to", from);
    }

    if(node.beginElement) {
        node.beginElement();
    } else { // fallback: skip animation
        let attr = node.getAttribute("attributeName");
        node.parentNode.setAttribute(attr, node.getAttribute("to"));
    }
}

function toArray(items) {
    return Array.prototype.slice.call(items);
}

It’s rather pleasing that our code here only has to know about <animate>’s declarative instructions; note how the fallback reads the desired target state from <animate> and applies it directly to the corresponding shape element. So this implementation isn’t tied to our particular icons.

Thanks to the various friends and colleagues who patiently assisted in figuring this out. (The actual, err, path was even more convoluted than described here.)

  1. It appears this doesn't work for arbitrary shapes though: There needs to be some correlation between to and from (e.g. equal number of points within the respective shape definition) for the animation to be calculated correctly.  ↩

TAGS