tl;dr

This article is a tutorial in creating animations with SVG + SMIL. I start with the SVG from part 1 and part 2 of our algorithmic metamorphant article series and create a vectorised loading animation. You can find the final code example in the algorithmic-metamorphant-logo github repo tag third-blog-article.

The goal: A vectorized loading animation for the web

In part 1 and part 2 of this series we refactored the metamorphant logo to a simplified SVG version based on algorithmic construction rules. This SVG will be our starting point. We will not change its geometry any more in this article.

We will create a loading animation similar to the first metamorphant loading animation created in a sloppy, hackish way with Synfig. Our animation will be optimised for use on the web by using native web technology. This time we will not compromise on exactness. As our animation description is algorithmic as well, the loading animation can be easily reproduced when the details of the logo are changed.

Animating SVG

In order to achieve our goal of animating the logo directly, we need a way to animate the SVG vector art. In former times, people would not have used SVG for that purpose, but rather some plugin-based technology like the proprietary Adobe Flash.

As of 2020, it is possible to create vector animations for the web using web native technology. Unfortunately, there is more than one canonical way to do it. To be honest, it’s a mess.

SMIL was originally intended by W3C to become the main animation method for SVG, but according to SVG working group’s github issues this is a point of debate.

CSS Animations

With the last changes in CSS Specifications, CSS got more powerful in terms of animating elements on a web page. Since the CSS standard was modularized, the available bits and pieces live in different specification modules.

  • CSS Transitions is used for animating between CSS property changes. It is specific to this purpose.
  • CSS Animations Level 1 gives the CSS author more control over the animation details than CSS Transitions. It provides a language to describe complete animations of CSS properties.
  • Web Animations focuses more on the timing and interaction between a web page and an animation. It is more general and agnostic of the concrete SVG animation technology used.

All CSS Animation standards focus on animation of CSS properties only. This is logical, as the whole idea behind CSS is the separation of concerns between content and presentation. In practise, this limits CSS animations to SVG presentation attributes. This is the subset of SVG element attributes, that are exposed as CSS properties.

Javascript

Another approach for animating SVG vector graphics is using the browser’s JavaScript Engine to manipulate the SVG DOM. In particular this becomes simpler when using a reactive JavaScript framework and modeling time as events. If you do not want to use vanilla JavaScript a.k.a. modern days assembly language, you can resort to more convenient transpiled languages like Elm or ClojureScript + re-frame. Maybe you can achieve the same using the WebAssembly compilation features of other languages. For example Go supports WebAssembly as compilation target. However, I am not sure how far you get when it comes to SVG DOM manipulation.

This is a common concern e.g. in browser games. There, animation will not be limited to time as event source, but will have to react to other events like button presses, mouse movement or even network interaction with other players. In this case using a full-fledged language is really natural. When it comes to simple time-based animations, it feels like overkill and less declarative than e.g. abstract keyframe descriptions.

SVG + SMIL

The SVG 1.1 Standard includes some animation properties itself, hinting to SMIL and SMIL Animation as the standard way of describing SVG animations. SVG serves as a host language for SMIL in that case.

This is the approach I will take in this article.

SVG + SMIL Building blocks

I will first explore all bits and pieces you need to create our animation. In the end you will put them together to a complete and working animation.

SVG animations work by describing the temporal behaviour of our elements’ attributes. So for example if you want a circle to move from the left to the right, you can write

  <circle cx="50" cy="50" r="50" fill="#02324b">
    <animate attributeName="cx" from="50" to="350" begin="0s" dur="3s" end="click" repeatCount="indefinite" />
  </circle>

Neat! You see how the attribute cx changes over the duration of 3 seconds from cx="50" to cx="350". The end="click" helps you stop the animation while reading this article. Just click the circle. Now let us learn about using keyTimes and values for more complex animations by making the circle bounce back and forth

  <circle cx="50" cy="50" r="50" fill="#02324b">
    <animate attributeName="cx" values="50 ; 350 ; 50" keyTimes="0 ; 0.5 ; 1.0" dur="6s" end="click" repeatCount="indefinite" />
  </circle>

You will have noticed that the <animate ... /> element is a child of the <circle ... /> element. Using it this way would force you to couple animation details with the geometry of your drawing. You can decouple the 2 concerns by writing

  <circle id="dot" cx="50" cy="50" r="50" fill="#02324b" />
  <animate xlink:href="#dot" attributeName="cx" values="50 ; 350 ; 50" keyTimes="0 ; 0.5 ; 1.0" dur="6s" end="click" repeatCount="indefinite" />

Great! Having this trick up our sleeves, we can describe our animation separately from the geometry of the original logo. We just need to reference its elements.

Before jumping into our little project of animating the metamorphant logo, I want to show you another feature we will need: composition of multiple animation elements. In our Synfig animation of the metamorphant logo we needed to describe the complete timeline up front. SVG+SMIL provides you with a way to decouple the timing by using relative references between different <animate ... /> elements.

As an example let me write our bounce animation in a different way:

  <circle id="anotherdot" cx="50" cy="50" r="50" fill="#02324b" />
  <animate id="forth" xlink:href="#anotherdot" attributeName="cx" from="50" to="350" begin="0s;back.end" dur="3s" />
  <animate id="back" xlink:href="#anotherdot" attributeName="cx" from="350" to="50" begin="forth.end" dur="3s" />

Of course, now you can not use the end="click" trick any more. Also you have to watch out for proper id naming: In case of embedding SVG like it’s done in this blog article, id attributes have to be globally unique. Uargh.

Line drawing effect

As a next step towards a metamorphant loading animation, I need a way to create a line drawing effect. This can be easily done using the SVG attributes stroke-dasharray and stroke-dashoffset. I will first demonstrate the effect of these attributes using a simple example path

<svg width="604" height="106">
  <path d="M 2 53 a 50 50, 0, 0, 0, 100 0 a 50 50, 0, 0, 1, 100 0 a 50 50, 0, 0, 0, 100 0 a 50 50, 0, 0, 1, 100 0 a 50 50, 0, 0, 0, 100 0 a 50 50, 0, 0, 1, 100 0" fill="none" stroke="#02324b" stroke-width="3" pathLength="1000" stroke-dasharray="1000" stroke-dashoffset="0" />
</svg>

The pathLength attribute serves as a normalization scale for the other attributes. stroke-dasharray enables creation of dashed paths. The extreme value of stroke-dasharray="1000" with a pathLength="1000" means no dashing at all. Finally, stroke-dashoffset controls the offset of the first dash. I will visualize that by some examples:

  • stroke-dasharray="20"
  • stroke-dasharray="200", stroke-dashoffset="0"
  • stroke-dasharray="200", stroke-dashoffset="200" (the opposite dash pattern)
  • stroke-dasharray="200", stroke-dashoffset="50" (a slight offset)
  • stroke-dasharray="200", stroke-dashoffset="450" (the offset is periodic with a period of 2x the dasharray)

You are now ready to create a dash array animation:

<svg width="604" height="106">
  <path d="M 2 53 a 50 50, 0, 0, 0, 100 0 a 50 50, 0, 0, 1, 100 0 a 50 50, 0, 0, 0, 100 0 a 50 50, 0, 0, 1, 100 0 a 50 50, 0, 0, 0, 100 0 a 50 50, 0, 0, 1, 100 0" fill="none" stroke="#02324b" stroke-width="3" pathLength="1000" stroke-dasharray="100" stroke-dashoffset="0">
    <animate attributeName="stroke-dashoffset" from="1000" to="0" dur="6s" end="click" repeatCount="indefinite" />
  </path>
</svg>

By using a stroke-dasharray="1000" for a pathLength="1000" you can achieve the desired drawing effect:

<svg width="604" height="106">
  <path d="M 2 53 a 50 50, 0, 0, 0, 100 0 a 50 50, 0, 0, 1, 100 0 a 50 50, 0, 0, 0, 100 0 a 50 50, 0, 0, 1, 100 0 a 50 50, 0, 0, 0, 100 0 a 50 50, 0, 0, 1, 100 0" fill="none" stroke="#02324b" stroke-width="3" pathLength="1000" stroke-dasharray="1000" stroke-dashoffset="0">
    <animate attributeName="stroke-dashoffset" from="1000" to="0" dur="6s" end="click" repeatCount="indefinite" />
  </path>
</svg>

This is enough to create a “connecting the dots” line animation. The dots will not be animated, yet. They will just be present all the time. The resulting Clojure code:

(defn logo [{:keys [dot-radius
                    line-width
                    corner-arc-radius
                    eye-offset] :or
             {dot-radius 55
              line-width 64
              corner-arc-radius 160
              eye-offset 265}}]
  (let [canvas {:width 1000 :height 1000}
        ...
        line-style {:stroke "#02324b" :path-length 1000 :stroke-width line-width :stroke-linejoin "round" :stroke-linecap "round" :fill "none" :marker-start "url(#bm)" :marker-end "url(#bm)"}]
      [:dali/page {:width "100%" :height "100%" :view-box (str view-box-padding-x " " view-box-padding-y " " total-width " "total-height)}
       [:defs
        [:circle {:id "bubbel" :cx 0 :cy 0 :r dot-radius :fill "#02324b"}]
        [:marker {:id "bm" :view-box (str "0 0 " dot-diameter " " dot-diameter) :ref-x dot-radius :ref-y dot-radius :marker-units "userSpaceOnUse" :marker-width dot-diameter :marker-height dot-diameter}
         [:use {:x dot-radius :y dot-radius :xlink:href "#bubbel"}]]]
       [:path (merge line-style {:stroke-dasharray 1000 :stroke-dashoffset 1000 :id "hulk"})
        :M [(:x inner-outline-dot)
            (:y inner-outline-dot)]
        ...]
       [:path (merge line-style {:stroke-dasharray 1000 :stroke-dashoffset 1000 :id "ear"})
        :M [(:x inner-ear-dot)
            (:y inner-ear-dot)]
        ...]
       [:use {:x (:x eye) :y (:y eye) :xlink:href "#bubbel"}]
       [:animate {:id "revealhulk" :xlink:href "#hulk" :attribute-name "stroke-dashoffset" :from "1000" :to "2000" :begin "0s;hideear.end+0.1s" :dur "1s" :fill "freeze"}]
       [:animate {:id "revealear" :xlink:href "#ear" :attribute-name "stroke-dashoffset" :from "1000" :to "2000" :begin "revealhulk.end" :dur "1s" :fill "freeze"}]
       [:animate {:id "hidehulk" :xlink:href "#hulk" :attribute-name "stroke-dashoffset" :from "0" :to "1000" :begin "revealear.end+0.1s" :dur "1s" :fill "freeze"}]
       [:animate {:id "hideear" :xlink:href "#ear" :attribute-name "stroke-dashoffset" :from "0" :to "1000" :begin "hidehulk.end" :dur "1s" :fill "freeze"}]
       ]))

The XML can be generated by lein run:

<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.2" width="100%" height="100%" viewBox="-23.0 -23.0 1046.0 1046.0">
  <defs>
    <circle cx="0" cy="0" r="55" id="bubbel" fill="#02324b"/>
    <marker id="bm" viewBox="0 0 110 110" refX="55" refY="55" markerUnits="userSpaceOnUse" markerWidth="110" markerHeight="110">
      <use x="55" y="55" xlink:href="#bubbel"/>
    </marker>
  </defs>
  <path stroke-dasharray="1000" stroke="#02324b" pathLength="1000" fill="none" stroke-linejoin="round" stroke-dashoffset="1000" stroke-linecap="round" marker-start="url(#bm)" stroke-width="64" id="hulk" d="M 381.9660112500899 719.254981731184 L 381.9660112500899 968.0 L 192.0 968.0 A 160 160, 0, 0, 1, 32 808 L 32.0 192.0 A 160 160, 0, 0, 1, 192 32 L 808.0 32.0 A 160 160, 0, 0, 1, 968 192 L 968.0 808.0 A 160 160, 0, 0, 1, 808 968" marker-end="url(#bm)"/>
  <path stroke-dasharray="1000" stroke="#02324b" pathLength="1000" fill="none" stroke-linejoin="round" stroke-dashoffset="1000" stroke-linecap="round" marker-start="url(#bm)" stroke-width="64" id="ear" d="M 381.9660112500899 163.7790070187262 L 381.9660112500899 340 A 160 160, 0, 0, 0, 541.9660112500899 500 L 704.6965145898587 500 L 704.6965145898587 808.0 A 160 160, 0, 0, 1, 544.6965145898587 968" marker-end="url(#bm)"/>
  <use x="735" y="265" xlink:href="#bubbel"/>
  <animate attributeName="stroke-dashoffset" fill="freeze" begin="0s;hideear.end+0.1s" from="1000" id="revealhulk" dur="1s" xlink:href="#hulk" to="2000"/>
  <animate attributeName="stroke-dashoffset" fill="freeze" begin="revealhulk.end" from="1000" id="revealear" dur="1s" xlink:href="#ear" to="2000"/>
  <animate attributeName="stroke-dashoffset" fill="freeze" begin="revealear.end+0.1s" from="0" id="hidehulk" dur="1s" xlink:href="#hulk" to="1000"/>
  <animate attributeName="stroke-dashoffset" fill="freeze" begin="hidehulk.end" from="0" id="hideear" dur="1s" xlink:href="#ear" to="1000"/>
</svg>

Connecting the dots animation of the metamorphant logo

In this example I use relative timing and animation element dependencies between the animation steps. You can find more detailed information about timing of animation steps in the SMIL standard sections SMIL 3.0 Timing and Synchronization and SMIL 3.0 Time Manipulations. The other “new” feature you will see in this example, is the ill-named fill="freeze" attribute, which ensures that the state of the animated element will keep the same even after an animation step finished.

Animating the eye

As the next element I will animate the eye. I will try to resemble the effect from the first metamorphant loading animation.

Animating opacity

Animating opacity should be a simple exercise with your newly-acquired knowledge:

  <circle id="revealeddot" cx="50" cy="50" r="50" fill="#02324b" />
  <animate id="reveal" xlink:href="#revealeddot" attributeName="opacity" from="0.0" to="1.0" begin="0s" dur="3s" repeatCount="indefinite" end="click" />

Animating scale

The second part is letting the eye grow. This is a bit harder to achieve and will help me to introduce another SMIL animation feature: <animateTransform>

Let me do a first naive experiment:

  <circle id="growndot" cx="50" cy="50" r="50" fill="#02324b" />
  <animateTransform id="grow" xlink:href="#growndot" attributeName="transform" type="scale" additive="sum" from="0 0" to="1 1" begin="0s" dur="3s" repeatCount="indefinite" end="click" />

You will see, that the transform also affects the reference coordinate system and therefore the positioning of the circle. We want it to grow from the center. I found one way to avoid this effect on Stackoverflow. Change from positioning by cx="50" cy="50" to positioning by transform="translate(50 50)".

  <circle id="anothergrowndot" r="50" fill="#02324b" transform="translate(50 50)" />
  <animateTransform id="grow" xlink:href="#anothergrowndot" attributeName="transform" type="scale" additive="sum" from="0 0" to="1 1" begin="0s" dur="3s" repeatCount="indefinite" end="click" />

Combining them yields the following updated eye definition and animation script:

       [:use {:id "eye"
              :transform (str "translate(" (:x eye) " " (:y eye) ")")
              :xlink:href "#bubbel"}]
       ...
       [:set {:xlink:href "#eye" :attribute-name "opacity" :to "0.0" :begin "0s" :fill "freeze"}]
       [:animate {:id "revealhulk" :xlink:href "#hulk" :attribute-name "stroke-dashoffset" :from "1000" :to "2000" :begin "0s;hideeye.end+0.1s" :dur "1s" :fill "freeze"}]
       [:animate {:id "revealear" :xlink:href "#ear" :attribute-name "stroke-dashoffset" :from "1000" :to "2000" :begin "revealhulk.end" :dur "1s" :fill "freeze"}]
       [:animate {:id "revealeye" :xlink:href "#eye" :attribute-name "opacity" :from "0.0" :to "1.0" :begin "revealear.end" :dur "1s" :fill "freeze"}]
       [:animateTransform {:id "groweye" :xlink:href "#eye" :attribute-name "transform" :type "scale" :additive "sum" :from "0 0" :to "1 1" :begin "revealear.end" :dur "1s"}]
       [:animate {:id "hidehulk" :xlink:href "#hulk" :attribute-name "stroke-dashoffset" :from "0" :to "1000" :begin "revealeye.end+0.1s" :dur "1s" :fill "freeze"}]
       [:animate {:id "hideear" :xlink:href "#ear" :attribute-name "stroke-dashoffset" :from "0" :to "1000" :begin "hidehulk.end" :dur "1s" :fill "freeze"}]
       [:animate {:id "hideeye" :xlink:href "#eye" :attribute-name "opacity" :from "1.0" :to "0.0" :begin "hideear.end" :dur "1s" :fill "freeze"}]
       [:animateTransform {:id "shrinkeye" :xlink:href "#eye" :attribute-name "transform" :type "scale" :additive "sum" :from "1 1" :to "0 0" :begin "hideear.end" :dur "1s"}]

The resuling XML:

  <use id="eye" transform="translate(735 265)" xlink:href="#bubbel"/>
  <set xlink:href="#eye" attributeName="opacity" to="0.0" begin="0s" fill="freeze"/>
  <animate attributeName="stroke-dashoffset" fill="freeze" begin="0s;hideeye.end+0.1s" from="1000" id="revealhulk" dur="1s" xlink:href="#hulk" to="2000"/>
  <animate attributeName="stroke-dashoffset" fill="freeze" begin="revealhulk.end" from="1000" id="revealear" dur="1s" xlink:href="#ear" to="2000"/>
  <animate attributeName="opacity" fill="freeze" begin="revealear.end" from="0.0" id="revealeye" dur="1s" xlink:href="#eye" to="1.0"/>
  <animateTransform type="scale" begin="revealear.end" from="0" id="groweye" dur="1s" xlink:href="#eye" additive="sum" attributeName="transform" to="1"/>
  <animate attributeName="stroke-dashoffset" fill="freeze" begin="revealeye.end+0.1s" from="0" id="hidehulk" dur="1s" xlink:href="#hulk" to="1000"/>
  <animate attributeName="stroke-dashoffset" fill="freeze" begin="hidehulk.end" from="0" id="hideear" dur="1s" xlink:href="#ear" to="1000"/>
  <animate attributeName="opacity" fill="freeze" begin="hideear.end" from="1.0" id="hideeye" dur="1s" xlink:href="#eye" to="0.0"/>
  <animateTransform type="scale" begin="hideear.end" from="1" id="shrinkeye" dur="1s" xlink:href="#eye" additive="sum" attributeName="transform" to="0"/>

And the resulting animation:

Connecting the dots animation of the metamorphant logo including eye fade in and fade out

Please note, that in the animateTransform elements, the fill="freeze" attributes have not been included, as it breaks the additive logic of transformations.

Animating the start and end dots

Until now, the start and end dots of hulk and ear were static. The next challenge is to animate them. Of course, we could cheat and switch to the same approach as in Synfig: do not directly animate the path, but overlay it with a hiding path and animate that. In fact, it would be easy to achieve. We would just have to duplicate the path, extend it a bit and increase its width to cover the logo path.

Instead I will try two different effects here. You got me: I also do it to introduce the missing <animateMotion> element.

Letting markers appear and vanish

To achieve a simple appear/vanish effect, it is enough to just animate the marker-start/marker-end attributes with the right timing. The animation script starts to grow complex and I would love to have some better abstractions…

       [:set {:xlink:href "#eye" :attribute-name "opacity" :to "0.0" :begin "0s" :fill "freeze"}]

       ; pre-revealhulk
       [:set {:xlink:href "#ear" :attribute-name "marker-start" :to "none" :begin "0s;hideeye.end+0.1s" :fill "freeze"}]
       [:set {:xlink:href "#ear" :attribute-name "marker-end" :to "none" :begin "0s;hideeye.end+0.1s" :fill "freeze"}]	   
       [:set {:xlink:href "#hulk" :attribute-name "marker-start" :to "none" :begin "0s;hideeye.end+0.1s" :fill "freeze"}]
       [:set {:xlink:href "#hulk" :attribute-name "marker-end" :to "url(#bm)" :begin "0s;hideeye.end+0.1s" :fill "freeze"}]

       ; revealhulk
       [:animate {:id "revealhulk" :xlink:href "#hulk" :attribute-name "stroke-dashoffset" :from "1000" :to "2000" :begin "0s;hideeye.end+0.1s" :dur "1s" :fill "freeze"}]
	   
	   ; post-revealhulk
       [:set {:xlink:href "#hulk" :attribute-name "marker-start" :to "url(#bm)" :begin "revealhulk.end" :fill "freeze"}]
	   
	   ; pre-revealear
       [:set {:xlink:href "#ear" :attribute-name "marker-end" :to "url(#bm)" :begin "revealhulk.end" :fill "freeze"}]
	   
	   ; revealear
       [:animate {:id "revealear" :xlink:href "#ear" :attribute-name "stroke-dashoffset" :from "1000" :to "2000" :begin "revealhulk.end" :dur "1s" :fill "freeze"}]
	   
	   ; post-revealear
       [:set {:xlink:href "#ear" :attribute-name "marker-start" :to "url(#bm)" :begin "revealear.end" :fill "freeze"}]
	   
	   ; revealeye + groweye
       [:animate {:id "revealeye" :xlink:href "#eye" :attribute-name "opacity" :from "0.0" :to "1.0" :begin "revealear.end" :dur "1s" :fill "freeze"}]
       [:animateTransform {:id "groweye" :xlink:href "#eye" :attribute-name "transform" :type "scale" :additive "sum" :from "0 0" :to "1 1" :begin "revealear.end" :dur "1s"}]
	   
	   ; pre-hidehulk
       [:set {:xlink:href "#hulk" :attribute-name "marker-end" :to "none" :begin "revealeye.end+0.1s" :fill "freeze"}]
	   
	   ; hidehulk
       [:animate {:id "hidehulk" :xlink:href "#hulk" :attribute-name "stroke-dashoffset" :from "0" :to "1000" :begin "revealeye.end+0.1s" :dur "1s" :fill "freeze"}]
	   
	   ; post-hidehulk
       [:set {:xlink:href "#hulk" :attribute-name "marker-start" :to "none" :begin "hidehulk.end" :fill "freeze"}]
	   
	   ; pre-hideear
       [:set {:xlink:href "#ear" :attribute-name "marker-end" :to "none" :begin "hidehulk.end" :fill "freeze"}]
	   
	   ; hideear
       [:animate {:id "hideear" :xlink:href "#ear" :attribute-name "stroke-dashoffset" :from "0" :to "1000" :begin "hidehulk.end" :dur "1s" :fill "freeze"}]
	   
	   ; post-hideear
       [:set {:xlink:href "#ear" :attribute-name "marker-start" :to "none" :begin "hideear.end" :fill "freeze"}]
	   
	   ; hideeye + shrinkeye
       [:animate {:id "hideeye" :xlink:href "#eye" :attribute-name "opacity" :from "1.0" :to "0.0" :begin "hideear.end" :dur "1s" :fill "freeze"}]
       [:animateTransform {:id "shrinkeye" :xlink:href "#eye" :attribute-name "transform" :type "scale" :additive "sum" :from "1 1" :to "0 0" :begin "hideear.end" :dur "1s"}]

In XML:

  <set xlink:href="#eye" attributeName="opacity" to="0.0" begin="0s" fill="freeze"/>
  <set xlink:href="#hulk" attributeName="marker-start" to="none" begin="0s;hideeye.end+0.1s" fill="freeze"/>
  <set xlink:href="#hulk" attributeName="marker-end" to="url(#bm)" begin="0s;hideeye.end+0.1s" fill="freeze"/>
  <set xlink:href="#ear" attributeName="marker-start" to="none" begin="0s;hideeye.end+0.1s" fill="freeze"/>
  <set xlink:href="#ear" attributeName="marker-end" to="none" begin="0s;hideeye.end+0.1s" fill="freeze"/>
  <animate attributeName="stroke-dashoffset" fill="freeze" begin="0s;hideeye.end+0.1s" from="1000" id="revealhulk" dur="1s" xlink:href="#hulk" to="2000"/>
  <set xlink:href="#hulk" attributeName="marker-start" to="url(#bm)" begin="revealhulk.end" fill="freeze"/>
  <set xlink:href="#ear" attributeName="marker-end" to="url(#bm)" begin="revealhulk.end" fill="freeze"/>
  <animate attributeName="stroke-dashoffset" fill="freeze" begin="revealhulk.end" from="1000" id="revealear" dur="1s" xlink:href="#ear" to="2000"/>
  <set xlink:href="#ear" attributeName="marker-start" to="url(#bm)" begin="revealear.end" fill="freeze"/>
  <animate attributeName="opacity" fill="freeze" begin="revealear.end" from="0.0" id="revealeye" dur="1s" xlink:href="#eye" to="1.0"/>
  <animateTransform type="scale" begin="revealear.end" from="0 0" id="groweye" dur="1s" xlink:href="#eye" additive="sum" attributeName="transform" to="1 1"/>
  <set xlink:href="#hulk" attributeName="marker-end" to="none" begin="revealeye.end+0.1s" fill="freeze"/>
  <animate attributeName="stroke-dashoffset" fill="freeze" begin="revealeye.end+0.1s" from="0" id="hidehulk" dur="1s" xlink:href="#hulk" to="1000"/>
  <set xlink:href="#hulk" attributeName="marker-start" to="none" begin="hidehulk.end" fill="freeze"/>
  <set xlink:href="#ear" attributeName="marker-end" to="none" begin="hidehulk.end" fill="freeze"/>
  <animate attributeName="stroke-dashoffset" fill="freeze" begin="hidehulk.end" from="0" id="hideear" dur="1s" xlink:href="#ear" to="1000"/>
  <set xlink:href="#ear" attributeName="marker-start" to="none" begin="hideear.end" fill="freeze"/>
  <animate attributeName="opacity" fill="freeze" begin="hideear.end" from="1.0" id="hideeye" dur="1s" xlink:href="#eye" to="0.0"/>
  <animateTransform type="scale" begin="hideear.end" from="1 1" id="shrinkeye" dur="1s" xlink:href="#eye" additive="sum" attributeName="transform" to="0 0"/>

The animation then looks like this:

Metamorphant logo animation with dots vanish/appear effect

I think this is already a pretty convincing result, isn’t it? Can we improve on it?

More complex marker animations

One way to improve the appearance of the animation would be to smoothen the appear/vanish effect of the dots.

SVG Marker elements can be animated like any other element. It does not help me, though: We wish to animate the marker at the point of use, i.e. the path, and not its definition. Otherwise it would apply to all its instances. Of course, we could generate a definition per instance like in this JavaScript example of styling SVG markers. To be honest, I do not think it is an elegant solution. In that case I would rather prefer to model the dots not as markers, at all.

Long story short: Animating markers is not one of the strengths of SVG+SMIL. I would even go so far to say that markers are not a strength of SVG, in general.

Animating dot movement

So, I want to think about an even more creative and complex way to improve upon our animation: I want to animate the dot as if it would be the end marker of the revealed line, leading the revelation!

Again we hit the limitations of the SVG marker concept: We can neither animate the revelation of the path and end the part revealed so far with a marker, nor can we place a marker-mid in a proper position to achieve the same effect.

In order to work around this issue, I will consider the dots as first class citizens and not model them as markers. In particular, they will be positioned precisely where they should be. The animateMotion feature of SVG+SMIL will help me. Using <mpath> it should be possible to move an object along a path.

A naive implementation of the effect looks like this:

  <path id="mopath" d="M 50 100 L 50 150 L 200 150 L 200 50 L 350 50 L 350 100" stroke-width="30" pathLength="1000" stroke="#02324b" stroke-dasharray="1000" stroke-dashoffset="0" stroke-linecap="round" fill="none">
    <animate attributeName="stroke-dashoffset" from="1000" to="0" begin="0s" dur="3s" end="click" repeatCount="indefinite" />
  </path>
  <circle cx="50" cy="50" r="50" fill="#02324b">
    <animateMotion begin="0s" dur="3s" end="click" repeatCount="indefinite" additive="replace">
	  <mpath xlink:href="#mopath" />
	</animateMotion>
  </circle>

You see, it does not work like this: The circle follows a path that is offset from its original position. Sara Soueidan’s excellent SVG+SMIL animation guide and the Stackoverflow thread about animateMotion start points helped me understand this. They mention the importance of an animation path starting at (0, 0).

How can I handle that for the situation I have? There are given paths (hulk and ear), that are already described and should just be reused. Handcrafting separate animation paths is not an option. Possible reactions: Either

  1. create the animation path at generation time by duplicating the given path and calculating the path starting at (0, 0) or
  2. set the circle to (0, 0) and reveal it only at start time of the animation to avoid flicker.

I decide in favour of the second choice.

  <path id="mopath" d="M 50 100 L 50 150 L 200 150 L 200 50 L 350 50 L 350 100" stroke-width="30" pathLength="1000" stroke="#02324b" stroke-dasharray="1000" stroke-dashoffset="0" stroke-linecap="round" fill="none">
    <animate attributeName="stroke-dashoffset" from="1000" to="0" begin="0s" dur="3s" end="click" repeatCount="indefinite" />
  </path>
  <circle cx="0" cy="0" r="50" fill="#02324b">
    <animateMotion begin="0s" dur="3s" end="click" repeatCount="indefinite" additive="replace">
	  <mpath xlink:href="#mopath" />
	</animateMotion>
  </circle>

Applying the effect to the logo animation yields:

       [:defs
        [:circle {:id "bubbel" :cx 0 :cy 0 :r dot-radius :fill "#02324b"}]]
       [:path (merge line-style {:stroke-dasharray 1000 :stroke-dashoffset 1000 :id "hulk"})
        ...]
       [:path (merge line-style {:stroke-dasharray 1000 :stroke-dashoffset 1000 :id "ear"})
        ...]
       [:use {:id "eye"
              :opacity "0.0"
              :transform (str "translate(" (:x eye) " " (:y eye) ")")
              :xlink:href "#bubbel"}]
       [:use {:id "hulk-start" :opacity 0.0 :x 0 :y 0 :xlink:href "#bubbel"}]
       [:use {:id "hulk-end" :opacity 0.0 :x 0 :y 0 :xlink:href "#bubbel"}]
       [:use {:id "ear-start" :opacity 0.0 :x 0 :y 0 :xlink:href "#bubbel"}]
       [:use {:id "ear-end" :opacity 0.0 :x 0 :y 0 :xlink:href "#bubbel"}]

       ; pre-revealhulk
       [:set {:xlink:href "#hulk-start" :attribute-name "opacity" :to "1.0" :begin "0s;hideeye.end+0.1s" :fill "freeze"}]
       [:set {:xlink:href "#hulk-end" :attribute-name "opacity" :to "1.0" :begin "0s;hideeye.end+0.1s" :fill "freeze"}]

       ; revealhulk
       [:animate {:id "revealhulk" :xlink:href "#hulk" :attribute-name "stroke-dashoffset" :from "1000" :to "2000" :begin "0s;hideeye.end+0.1s" :dur "1s" :fill "freeze"}]

       ; revealhulk dots
       [:animateMotion {:xlink:href "#hulk-start" :begin "0s;hideeye.end+0.1s" :dur "1s" :fill "freeze" :calc-mode "linear" :key-times "0 ; 1" :key-points "1 ; 0"}
        [:mpath {:xlink:href "#hulk"}]]
       [:animateMotion {:xlink:href "#hulk-end" :begin "0s;hideeye.end+0.1s" :dur "1s" :fill "freeze" :calc-mode "linear" :key-times "0 ; 1" :key-points "1 ; 1"}
        [:mpath {:xlink:href "#hulk"}]]

       ; pre-revealear
       [:set {:xlink:href "#ear-start" :attribute-name "opacity" :to "1.0" :begin "revealhulk.end" :fill "freeze"}]
       [:set {:xlink:href "#ear-end" :attribute-name "opacity" :to "1.0" :begin "revealhulk.end" :fill "freeze"}]

       ; revealear
       [:animate {:id "revealear" :xlink:href "#ear" :attribute-name "stroke-dashoffset" :from "1000" :to "2000" :begin "revealhulk.end" :dur "1s" :fill "freeze"}]

       ; revealear dots
       [:animateMotion {:xlink:href "#ear-start" :begin "revealhulk.end" :dur "1s" :fill "freeze" :calc-mode "linear" :key-times "0 ; 1" :key-points "1 ; 0"}
        [:mpath {:xlink:href "#ear"}]]
       [:animateMotion {:xlink:href "#ear-end" :begin "revealhulk.end" :dur "1s" :fill "freeze" :calc-mode "linear" :key-times "0 ; 1" :key-points "1 ; 1"}
        [:mpath {:xlink:href "#ear"}]]

       ; revealeye + groweye
       [:animate {:id "revealeye" :xlink:href "#eye" :attribute-name "opacity" :from "0.0" :to "1.0" :begin "revealear.end" :dur "1s" :fill "freeze"}]
       [:animateTransform {:id "groweye" :xlink:href "#eye" :attribute-name "transform" :type "scale" :additive "sum" :from "0 0" :to "1 1" :begin "revealear.end" :dur "1s"}]

       ; hidehulk
       [:animate {:id "hidehulk" :xlink:href "#hulk" :attribute-name "stroke-dashoffset" :from "0" :to "1000" :begin "revealeye.end+0.1s" :dur "1s" :fill "freeze"}]

       ; hidehulk dots
       [:animateMotion {:xlink:href "#hulk-start" :begin "revealeye.end+0.1s" :dur "1s" :fill "freeze" :calc-mode "linear" :key-times "0 ; 1" :key-points "0 ; 0"}
        [:mpath {:xlink:href "#hulk"}]]
       [:animateMotion {:xlink:href "#hulk-end" :begin "revealeye.end+0.1s" :dur "1s" :fill "freeze" :calc-mode "linear" :key-times "0 ; 1" :key-points "1 ; 0"}
        [:mpath {:xlink:href "#hulk"}]]

       ; post-hidehulk
       [:set {:xlink:href "#hulk-start" :attribute-name "opacity" :to "0.0" :begin "hidehulk.end" :fill "freeze"}]
       [:set {:xlink:href "#hulk-end" :attribute-name "opacity" :to "0.0" :begin "hidehulk.end" :fill "freeze"}]

       ; hideear
       [:animate {:id "hideear" :xlink:href "#ear" :attribute-name "stroke-dashoffset" :from "0" :to "1000" :begin "hidehulk.end" :dur "1s" :fill "freeze"}]

       ; hideear dots
       [:animateMotion {:xlink:href "#ear-start" :begin "hidehulk.end" :dur "1s" :fill "freeze" :calc-mode "linear" :key-times "0 ; 1" :key-points "0 ; 0"}
        [:mpath {:xlink:href "#ear"}]]
       [:animateMotion {:xlink:href "#ear-end" :begin "hidehulk.end" :dur "1s" :fill "freeze" :calc-mode "linear" :key-times "0 ; 1" :key-points "1 ; 0"}
        [:mpath {:xlink:href "#ear"}]]

       ; post-hideear
       [:set {:xlink:href "#ear-start" :attribute-name "opacity" :to "0.0" :begin "hideear.end" :fill "freeze"}]
       [:set {:xlink:href "#ear-end" :attribute-name "opacity" :to "0.0" :begin "hideear.end" :fill "freeze"}]       

       ; hideeye + shrinkeye
       [:animate {:id "hideeye" :xlink:href "#eye" :attribute-name "opacity" :from "1.0" :to "0.0" :begin "hideear.end" :dur "1s" :fill "freeze"}]
       [:animateTransform {:id "shrinkeye" :xlink:href "#eye" :attribute-name "transform" :type "scale" :additive "sum" :from "1 1" :to "0 0" :begin "hideear.end" :dur "1s"}]
...

Resp. in XML:

  <defs>
    <circle cx="0" cy="0" r="55" id="bubbel" fill="#02324b"/>
  </defs>
  <path stroke-dasharray="1000" stroke="#02324b" pathLength="1000" fill="none" stroke-linejoin="round" stroke-dashoffset="1000" stroke-linecap="round" marker-start="none" stroke-width="64" id="hulk" d="M 381.9660112500899 719.254981731184 L 381.9660112500899 968.0 L 192.0 968.0 A 160 160, 0, 0, 1, 32 808 L 32.0 192.0 A 160 160, 0, 0, 1, 192 32 L 808.0 32.0 A 160 160, 0, 0, 1, 968 192 L 968.0 808.0 A 160 160, 0, 0, 1, 808 968" marker-end="none"/>
  <path stroke-dasharray="1000" stroke="#02324b" pathLength="1000" fill="none" stroke-linejoin="round" stroke-dashoffset="1000" stroke-linecap="round" marker-start="none" stroke-width="64" id="ear" d="M 381.9660112500899 163.7790070187262 L 381.9660112500899 340 A 160 160, 0, 0, 0, 541.9660112500899 500 L 704.6965145898587 500 L 704.6965145898587 808.0 A 160 160, 0, 0, 1, 544.6965145898587 968" marker-end="none"/>
  <use id="eye" opacity="0.0" transform="translate(735 265)" xlink:href="#bubbel"/>
  <use id="hulk-start" opacity="0.0" x="0" y="0" xlink:href="#bubbel"/>
  <use id="hulk-end" opacity="0.0" x="0" y="0" xlink:href="#bubbel"/>
  <use id="ear-start" opacity="0.0" x="0" y="0" xlink:href="#bubbel"/>
  <use id="ear-end" opacity="0.0" x="0" y="0" xlink:href="#bubbel"/>
  <set xlink:href="#hulk-start" attributeName="opacity" to="1.0" begin="0s;hideeye.end+0.1s" fill="freeze"/>
  <set xlink:href="#hulk-end" attributeName="opacity" to="1.0" begin="0s;hideeye.end+0.1s" fill="freeze"/>
  <animate attributeName="stroke-dashoffset" fill="freeze" begin="0s;hideeye.end+0.1s" from="1000" id="revealhulk" dur="1s" xlink:href="#hulk" to="2000"/>
  <animateMotion xlink:href="#hulk-start" begin="0s;hideeye.end+0.1s" dur="1s" fill="freeze" calcMode="linear" keyTimes="0 ; 1" keyPoints="1 ; 0">
    <mpath xlink:href="#hulk"/>
  </animateMotion>
  <animateMotion xlink:href="#hulk-end" begin="0s;hideeye.end+0.1s" dur="1s" fill="freeze" calcMode="linear" keyTimes="0 ; 1" keyPoints="1 ; 1">
    <mpath xlink:href="#hulk"/>
  </animateMotion>
  <set xlink:href="#ear-start" attributeName="opacity" to="1.0" begin="revealhulk.end" fill="freeze"/>
  <set xlink:href="#ear-end" attributeName="opacity" to="1.0" begin="revealhulk.end" fill="freeze"/>
  <animate attributeName="stroke-dashoffset" fill="freeze" begin="revealhulk.end" from="1000" id="revealear" dur="1s" xlink:href="#ear" to="2000"/>
  <animateMotion xlink:href="#ear-start" begin="revealhulk.end" dur="1s" fill="freeze" calcMode="linear" keyTimes="0 ; 1" keyPoints="1 ; 0">
    <mpath xlink:href="#ear"/>
  </animateMotion>
  <animateMotion xlink:href="#ear-end" begin="revealhulk.end" dur="1s" fill="freeze" calcMode="linear" keyTimes="0 ; 1" keyPoints="1 ; 1">
    <mpath xlink:href="#ear"/>
  </animateMotion>
  <animate attributeName="opacity" fill="freeze" begin="revealear.end" from="0.0" id="revealeye" dur="1s" xlink:href="#eye" to="1.0"/>
  <animateTransform type="scale" begin="revealear.end" from="0 0" id="groweye" dur="1s" xlink:href="#eye" additive="sum" attributeName="transform" to="1 1"/>
  <animate attributeName="stroke-dashoffset" fill="freeze" begin="revealeye.end+0.1s" from="0" id="hidehulk" dur="1s" xlink:href="#hulk" to="1000"/>
  <animateMotion xlink:href="#hulk-start" begin="revealeye.end+0.1s" dur="1s" fill="freeze" calcMode="linear" keyTimes="0 ; 1" keyPoints="0 ; 0">
    <mpath xlink:href="#hulk"/>
  </animateMotion>
  <animateMotion xlink:href="#hulk-end" begin="revealeye.end+0.1s" dur="1s" fill="freeze" calcMode="linear" keyTimes="0 ; 1" keyPoints="1 ; 0">
    <mpath xlink:href="#hulk"/>
  </animateMotion>
  <set xlink:href="#hulk-start" attributeName="opacity" to="0.0" begin="hidehulk.end" fill="freeze"/>
  <set xlink:href="#hulk-end" attributeName="opacity" to="0.0" begin="hidehulk.end" fill="freeze"/>
  <animate attributeName="stroke-dashoffset" fill="freeze" begin="hidehulk.end" from="0" id="hideear" dur="1s" xlink:href="#ear" to="1000"/>
  <animateMotion xlink:href="#ear-start" begin="hidehulk.end" dur="1s" fill="freeze" calcMode="linear" keyTimes="0 ; 1" keyPoints="0 ; 0">
    <mpath xlink:href="#ear"/>
  </animateMotion>
  <animateMotion xlink:href="#ear-end" begin="hidehulk.end" dur="1s" fill="freeze" calcMode="linear" keyTimes="0 ; 1" keyPoints="1 ; 0">
    <mpath xlink:href="#ear"/>
  </animateMotion>
  <set xlink:href="#ear-start" attributeName="opacity" to="0.0" begin="hideear.end" fill="freeze"/>
  <set xlink:href="#ear-end" attributeName="opacity" to="0.0" begin="hideear.end" fill="freeze"/>
  <animate attributeName="opacity" fill="freeze" begin="hideear.end" from="1.0" id="hideeye" dur="1s" xlink:href="#eye" to="0.0"/>
  <animateTransform type="scale" begin="hideear.end" from="1 1" id="shrinkeye" dur="1s" xlink:href="#eye" additive="sum" attributeName="transform" to="0 0"/>

It is worth pointing out the keyTimes="0 ; 1" keyPoints="1 ; 0" trick for reversing the path animation direction resp. the keyTimes="0 ; 1" keyPoints="1 ; 1" trick for setting the position to the end of the path. I took it from a Stackoverflow thread about reversing the direction of SVG mpath animations.

Another pitfall was the calcMode="linear" setting in the <animateMotion ... /> elements. Originally I left the attribute calcMode out. The result was unpredictable behaviour: While in Firefox everything looked fine, Chromium garbled the animation. Reason: The SVG Animation standard specifies a different default value of calcMode="paced" for <animateMotion ... />.

Our final animation is finished, yay!

Metamorphant logo animation

Closing notes

Working with SVG+SMIL is fun. I definitely enjoyed describing this animation as code.

For me, it also was a nostalgic journey back to the roots, reminiscent of one of my first more complex computer graphics experiences: a little family project together with my father and my brother (yes, I confess, that’s kinda geeky); based on model calculation data from an actual simulation, we created a 3D animation of the fusion of two atomic nuclei. That must have been 1996. I remember our brand new Pentium Pro 200 Mhz machine just arrived at home. It ran the brand new SuSE Linux operating system. So much room for exploration! Our tool of choice back then was PovRay and its rudimentary animation capabilities.

At the same time, this experience showed me the limitations of SVG and SMIL. The languages are fine as a target format for animations, but normally you will want to use some tooling based on them. They just don’t seem to be intended for direct ‘animations as code’ development, which poses the interesting question: What would a good DSL for animations look like?



Post header background image by Pexels from Pixabay.


Contact us