The Making of: Netlify’s Million Devs SVG Animation Site

Avatar of Sarah Drasner
Sarah Drasner on

The following article captures the process of building the Million Developers microsite for Netlify. This project was built by a few folks and we’ve captured some parts of the process of building it here- focusing mainly on the animation aspects, in case any are helpful to others building similar experiences.

Building a Vue App out of an SVG

The beauty of SVG is you can think of it, and the coordinate system, as a big game of battleship. You’re really thinking in terms of x, y, width, and height.

<div id="app">
   <app-login-result-sticky v-if="user.number" />
   <app-github-corner />

   <app-header />

   <!-- this is one big SVG -->
   <svg id="timeline" xmlns="http://www.w3.org/2000/svg" :viewBox="timelineAttributes.viewBox">
     <!-- this is the desktop path -->
     <path
       class="cls-1 timeline-path"
       transform="translate(16.1 -440.3)"
       d="M951.5,7107..."
     />
     <!-- this is the path for mobile -->
     <app-mobilepath v-if="viewportSize === 'small'" />

     <!-- all of the stations, broken down by year -->
     <app2016 />
     <app2017 />
     <app2018 />
     <app2019 />
     <app2020 />

     <!-- the 'you are here' marker, only shown on desktop and if you're logged in -->
     <app-youarehere v-if="user.number && viewportSize === 'large'" />
   </svg>
 </div>

Within the larger app component, we have the large header, but as you can see, the rest is one giant SVG. From there, we broke down the rest of the giant SVG into several components:

  • Candyland-type paths for both desktop and mobile, shown conditionally by a state in the Vuex store
  • There are 27 stations, not including their text counterparts, and many decorative components like bushes, trees, and streetlamps, which is a lot to keep track of in one component, so they’re broken down by year
  • The ‘you are here’ marker, only shown on desktop and if you’re logged in

SVG is wonderfully flexible because not only can we draw absolute and relative shapes and paths within that coordinate system, we can also draw SVGs within SVGs. We just need to defined the x, y, width and height of those SVGs and we can mount them inside the larger SVG, which is exactly what we’re going to do with all these components so that we can adjust their placement whenever needed. The <g> within the components stands for group, you can think of them a little like divs in HTML.

So here’s what this looks like within the year components:

<template>
 <g>
   <!-- decorative components -->
   <app-tree x="650" y="5500" />
   <app-tree x="700" y="5550" />
   <app-bush x="750" y="5600" />

   <!-- station component -->
   <app-virtual x="1200" y="6000" xSmall="50" ySmall="15100" />
   <!-- text component, with slots -->
   <app-text
     x="1400"
     y="6500"
     xSmall="50"
     ySmall="15600"
     num="20"
     url-slug="jamstack-conf-virtual"
   >
     <template v-slot:date>May 27, 2020</template>
     <template v-slot:event>Jamstack Conf Virtual</template>
   </app-text>

   ...
 </template>

<script>
...

export default {
 components: {
   // loading the decorative components in syncronously
   AppText,
   AppTree,
   AppBush,
   AppStreetlamp2,
   // loading the heavy station components in asyncronously
   AppBuildPlugins: () => import("@/components/AppBuildPlugins.vue"),
   AppMillion: () => import("@/components/AppMillion.vue"),
   AppVirtual: () => import("@/components/AppVirtual.vue"),
 },
};
...
</script>

Within these components, you can see a number of patterns:

  • We have bushes and trees for decoration that we can sprinkle around viax and y values via props
  • We can have individual station components, which also have two different positioning values, one for large and small devices
  • We have a text component, which has three available slots, one for the date, and two for two different text lines
  • We’re also loading in the decorative components synchronously, and loading those heavier SVG stations async

SVG Animation

Header animation for Million Devs

The SVG animation is done with GreenSock (GSAP), with their new ScrollTrigger plugin. I wrote up a guide on how to work with GSAP for their latest 3.0 release earlier this year. If you’re unfamiliar with this library, that might be a good place to start.

Working with the plugin is thankfully straightforward, here is the base of the functionality we’ll need:

import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger.js";
import { mapState } from "vuex";

gsap.registerPlugin(ScrollTrigger);

export default {
 computed: {
   ...mapState([
     "toggleConfig",
     "startConfig",
     "isAnimationDisabled",
     "viewportSize",
   ]),
 },
 ...
 methods: {
   millionAnim() {
     let vm = this;
     let tl;
     const isScrollElConfig = {
       scrollTrigger: {
         trigger: `.million${vm.num}`,
         toggleActions: this.toggleConfig,
         start: this.startConfig,
       },
       defaults: {
         duration: 1.5,
         ease: "sine",
       },
     };
   }
 },
 mounted() {
   this.millionAnim();
 },
};

First, we’re importing gsap and the package we need, as well as state from the Vuex store. I put the toggleActions and start config settings in the store and passed them into each component because while I was working, I needed to experiment with which point in the UI I wanted to trigger the animations, this kept me from having to configure each component separately.

Those configurations in the store look like this:

export default new Vuex.Store({
  state: {
    toggleConfig: `play pause none pause`,
    startConfig: `center 90%`,
  }
}

This configuration breaks down to

  • toggleConfig: play the animation when it passes down the page (another option is to say restart and it will retrigger if you see it again), it pauses when it is out of the viewport (this can slightly help with perf), and that it doesn’t retrigger in reverse when going back up the page.
  • startConfig is stating that when the center of the element is 90% down from the height of the viewport, to trigger the animation to begin.

These are the settings we decided on for this project, there are many others! You can understand all of the options with this video.

For this particular animation, we needed to treat it a little differently if it was a banner animation which didn’t need to be triggered on scroll or if it was later in the timeline. We passed in a prop and used that to pass in that config depending on the number in props:

if (vm.num === 1) {
  tl = gsap.timeline({
    defaults: {
      duration: 1.5,
      ease: "sine",
    },
  });
} else {
  tl = gsap.timeline(isScrollElConfig);
}

Then, for the animation itself, I’m using what’s called a label on the timeline, you can think of it like identifying a point in time on the playhead that you may want to hang animations or functionality off of. We have to make sure we use the number prop for the label too, so we keep the timelines for the header and footer component separated.

tl.add(`million${vm.num}`)
...
.from(
  "#front-leg-r",
  {
    duration: 0.5,
    rotation: 10,
    transformOrigin: "50% 0%",
    repeat: 6,
    yoyo: true,
    ease: "sine.inOut",
  },
  `million${vm.num}`
)
.from(
  "#front-leg-l",
  {
    duration: 0.5,
    rotation: 10,
    transformOrigin: "50% 0%",
    repeat: 6,
    yoyo: true,
    ease: "sine.inOut",
  },
  `million${vm.num}+=0.25`
);

There’s a lot going on in the million devs animation so I’ll just isolate one piece of movement to break down: above we have the girls swinging legs. We have both legs swinging separately, both are repeating several times, and that yoyo: true lets GSAP know that I’d like the animation to reverse every other alteration. We’re rotating the legs, but what makes it realistic is the transformOrigin starts at the center top of the leg, so that when it’s rotating, it’s rotating around the knee axis, like knees do :)

Adding an Animation Toggle

animation toggle

We wanted to give users the ability to explore the site without animation, should they have a vestibular disorder, so we created a toggle for the animation play state. The toggle is nothing special- it updates state in the Vuex store through a mutation, as you might expect:

export default new Vuex.Store({
  state: {
    ...
    isAnimationDisabled: false,
  },
  mutations: {
    updateAnimationState(state) {
      state.isAnimationDisabled = !state.isAnimationDisabled
    },
  ...
})

The real updates happen in the topmost App component where we collect all of the animations and triggers, and then adjust them based on the state in the store. We watch the isAnimationDisabled property for changes, and when one occurs, we grab all instances of scrolltrigger animations in the app. We don’t .kill() the animations, which one option, because if we did, we wouldn’t be able to restart them.

Instead, we either set their progress to the final frame if animations are disabled, or if we’re restarting them, we set their progress to 0 so they can restart when they are set to fire on the page. If we had used .restart() here, all of the animations would have played and we wouldn’t see them trigger as we kept going down the page. Best of both worlds!

watch: {
   isAnimationDisabled(newVal, oldVal) {
     ScrollTrigger.getAll().forEach((trigger) => {
       let animation = trigger.animation;
       if (newVal === true) {
         animation && animation.progress(1);
       } else {
         animation && animation.progress(0);
       }
     });
   },
 },

SVG Accessibility

I am by no means an accessibility expert, so please let me know if I’ve misstepped here- but I did a fair amount of research and testing on this site, and was pretty excited that when I tested on my Macbook via voiceover, the site’s pertinent information was traversable, so I’m sharing what we did to get there.

For the initial SVG that cased everything, we didn’t apply a role so that the screenreader would traverse within it. For the trees and bushes, we applied role="img" so the screenreader would skip it and any of the more detailed stations we applied a unique id and title, which was the first element within the SVG. We also applied role="presentation".

<svg
   ...
   role="presentation"
   aria-labelledby="analyticsuklaunch"
 >
   <title id="analyticsuklaunch">Launch of analytics</title>

I learned a lot of this from this article by Heather Migliorisi, and this great article by Leonie Watson.

The text within the SVG does announce itself as you tab through the page, and the link is found, all of the text is read. This is what that text component looks like, with those slots mentioned above.

<template>
 <a
   :href="`https://www.netlify.com/blog/2020/08/03/netlify-milestones-on-the-road-to-1-million-devs/#${urlSlug}`"
 >
   <svg
     xmlns="http://www.w3.org/2000/svg"
     width="450"
     height="250"
     :x="svgCoords.x"
     :y="svgCoords.y"
     viewBox="0 0 280 115.4"
   >
     <g :class="`textnode text${num}`">
       <text class="d" transform="translate(7.6 14)">
         <slot name="date">Jul 13, 2016</slot>
       </text>
       <text class="e" transform="translate(16.5 48.7)">
         <slot name="event">Something here</slot>
       </text>
       <text class="e" transform="translate(16.5 70)">
         <slot name="event2" />
       </text>
       <text class="h" transform="translate(164.5 104.3)">View Milestone</text>
     </g>
   </svg>
 </a>
</template>

Here’s a video of what this sounds like if I tab through the SVG on my Mac:

If you have further suggestions for improvement please let us know!

The repo is also open source if you want to check out the code or file a PR.

Thanks a million (pun intended) to my coworkers Zach Leatherman and Hugues Tennier who worked on this with me, their input and work was invaluable to the project, it only exists from teamwork to get it over the line! And so much respect to Alejandro Alvarez who did the design, and did a spectacular job. High fives all around. 🙌