Animating SVGs with Vivus.js

The Idea

As I've been spiffing up my portfolio, I wanted to add some creative design elements that would make it more unique and also give me a chance to sharpen my UI skills. My idea was to integrate some sort of minimalistic, mountain or landscape line art at the bottom of each page of my site -- a footer, I guess, but for no purpose other than aesthetics!

To take it a step further, since it was line art, I wanted it drawn in real-time, animating across the screen once a visitor landed on my portfolio page. I consulted with ChatGPT and found the Vivus.js SVG animation library -- exactly what I was looking for.

I signed up for the Adobe Stock free trial, searched "mountain line art", found some images I liked, downloaded them as JPEGs, then used ConvertIO to turn them into SVGs.

The creator of Vivus.js made a playground for the library, a site on Github Pages that allows you to drag and drop any SVG into the UI and see how different library properties makes the animation look. It's awesome. I tested this out with the various SVGs I downloaded and decided that I liked the striated mountain range design the best. Although it was a more complex design that I was intending (not just one minimal uninterrupted line depicting a mountain), I liked the animation. It was as if a hundred little hands were drawing separate parts of of the piece, and after a few seconds the masterpiece came together as one.

Getting the Static SVGs Up and Running

The first goal was just to get a static SVG displayed on the page. This was fairly simple -- open the SVG file in a text editor, copy the code, paste it inside a React component, then import that React component into my RootLayout component within my portfolio codebase. This way, just like my navigation menu, the SVG would appear on every page wrapped by RootLayout.

First iteration, the SVG is waaaaay too big! I made some style tweaks:

  • I adjusted the SVG height to 190px and set the SVG width to 100%. I wanted the design to span the entire page width and look nice on phones, tablets, and computers. I also changed the preserveAspectRatio to none to allow for dynamic sizing. Lucky for me, mountains look great both low and wide and tall and narrow.
  • I set some minimum screen height and flex properties in the parent component so the SVG would always sit flush with the bottom of the screen.
  • I realized the SVG had a lot of unnecessary whitespace around it. Rather than trying to create some negative margin style mess, I found a helpful CodePen that took in the existing SVG and output new SVG viewBox coordinates to trim away the whitespace.

Bringing It to Life with Vivus

Once I had the static SVG displaying how I wanted, I brought in Vivus.js to animate it. Initially, the animation didn't work -- the SVG I downloaded used the fill property to render the shape, while the Vivus library required SVGs to use the stroke property in order for the animation to work properly.

Both fill and stroke can be used to render SVGs -- stroke determines the color or pattern of the outline of the shape, while fill determines the color or pattern of the interior of the shape. For my mountain SVG, fill="black" meant that the interior space between the individual path coordinates would be filled with color black. But the stroke="none" property meant that the border of the path wasn't being visualized, so there was no outline to trace or animate.

Luckily, this was an easy fix. All I had to do was change the fill property to fill=none and add stroke=black and strokeWidth=20. The <path d="M51214 13526 c-108 -35 -838 -533 -1374 -937..." /> tag nested inside of the <svg> tag stayed the same, and now the new stroke properties would allow that path outline to be traced linearly.

After fixing the hiccup, I wrote a React hook to initialize a new Vivus instance (Vivus.js is a constructor-based library) and attached it to my SVG element using a ref. The hook doesn’t have any dependencies and only runs when the component mounts. This is how I wanted it -- when a visitor navigates from Home to About to a specific project page or blog post on my site, I don't want the animation to be re-drawn every time the site content changes. The visitor gets the delight of the animation upon first view, but then it stays a static image to avoid being distracting.

After writing the hook, attaching the ref, and customizing fields on the new Vivus instance, it was time to test everything together:

Voila! An animated mountain drawing. This was a great project to get familiar with the inner-workings of SVGs. I'm happy with the look of it right now, but I'm sure I'll play around with other animations and replace this design with other ones in the future -- maybe a more minimalist single-line landscapes, or map topography lines.

Here is some of the code used to make this work:

/app/layout.tsx
import "./globals.css";
import Link from "next/link";
import MountainAnimation from "./components/MountainAnimation";

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {

  return (
    <html lang="en">
      <body className="bg-stone-100 font-serif flex justify-center text-lg">
        <main className="w-full min-h-screen flex flex-col justify-between">
          <div>
            <nav className="flex justify-between items-center">
              <Link className="p-4 block sm:hidden text-xl font-bold" href="/">SC</Link>
              <Link className="p-4 hidden sm:block" href="/">Stephanie Coates</Link>
              <div>
                <Link className="p-4 " href="/about">About</Link>
                <Link className="p-4 " href="/projects">Projects</Link>
                <Link className="p-4 " href="/writing">Writing</Link>
              </div>
            </nav>
            <div className="flex flex-col items-center">
              <div className="flex flex-col max-w-screen-md p-4">
                {children}
              </div>
            </div>
          </div>
          <MountainAnimation />
        </main>
      </body>
    </html>
  );
}
MountainAnimation.tsx
'use client'
import React, { useEffect, useRef } from 'react';
import Vivus from 'vivus';

const MountainAnimation = () => {
  const svgRef = useRef(null);
  useEffect(() => {
    if (svgRef.current) {
      new Vivus(svgRef.current, {
        type: 'delayed',
        duration: 1000,
        animTimingFunction: Vivus.EASE_IN,
      });
    }
  }, []);

  return (
    <svg ref={svgRef} version="1.0" xmlns="http://www.w3.org/2000/svg"
      width="100%" height="190" viewBox="0 658.26 7454.06 1353.86"
      preserveAspectRatio="none" strokeWidth="20" fill="none" stroke="black">
      <g transform="translate(0.000000,2012.000000) scale(0.100000,-0.100000)">
        <path
          d="M51214 13526 c-108 -35 -838 -533 -1374 -937 -408 -307 -403 -304
          -740 -573 -582 -466 -1034 -878 -1445 -1316 -156 -166 -438 -492 -564 -650
          -219 -276 -351 -418 -439 -474 -61 -38 -153 -59 -231 -53 -94 7 -186 -9 -266
          -47 -68 -32 -104 -59 -480 -366 -223 -182 -272 -220 -283 -220 -5 0 -89 50
          -187 111 -251 157 -391 236 -615 347 -504 251 -748 308 -1035 242 -150 -34
          -240 -81 -530 -272 -88 -58 -241 -157 -340 -220 -180 -115 -699 -452 -1065
          -693 -265 -174 -1614 -1074 -2043 -1362 -191 -129 -654 -438 -1028 -688 l-681" //shortened for brevity, actual path is over 2000 lines long
        />

      </g>
    </svg>
  );
};

export default MountainAnimation;