Animate an SVG element along a fixed path with Framer Motion in React

Dec, 14 2022

Framer Motion is an awesome library for animating most HTML items in your DOM. It also works well with SVG elements to unlock some cool UI effects for your React project.

Here's what we'll be building:

Project output

You can jump straight to the code, if you'd like.

Animating a path element with Framer Motion

First you'll need to instal the Framer Motion library

npm i framer-motion

Next, let's create a simple React component with two SVG paths inside. One will act as the foreground and the other will act as the background.

export default function App() { return ( <div className="App" style={{ minHeight: 500, display: "flex", flexDirection: "column", justifyContent: "center", alignItems: "center", background: "#d9eefd" }} > <svg width="500" height="50" viewBox="0 0 500 30"> <path stroke="white" strokeWidth="10" strokeLinecap="round" d="M15 15 H490" /> <path d="M15 15 H490" stroke="#1f88eb" strokeWidth="10" strokeLinecap="round" /> </svg> </div> ); }

You should be left with just the foreground line in the center of your component:

Starting point

Now import motion from our Framer library and update the svg and foreground path tag.

import { motion } from "framer-motion"; export default function App() { return ( <div className="App" style={{ minHeight: 500, display: "flex", flexDirection: "column", justifyContent: "center", alignItems: "center", background: "#d9eefd" }} > <motion.svg width="500" height="50" viewBox="0 0 500 30"> <path stroke="white" strokeWidth="10" strokeLinecap="round" d="M15 15 H490" /> <motion.path d="M15 15 H490" stroke="#1f88eb" strokeWidth="10" strokeLinecap="round" /> </motion.svg> </div> ); }

There should be no change to our output component. Notice how the background path remains without a motion tag. This element does not need to be animated.

At the top of your component add a few variables:

const transition = { repeat: Infinity, bounce: 0.75, type: "spring", duration: 2 }; const progress = 50;

Transition will define the animation type, and progress will tell the foreground where to stop. In this case, 50% of it's path length.

Update your foreground path with our two new variables:

<motion.path d="M15 15 H490" stroke="#1f88eb" strokeWidth="10" strokeLinecap="round" initial={{ pathLength: 0 }} animate={{ pathLength: progress / 100 }} transition={transition} />

Our line should now spring forward and settle around the half way mark.

Project output

That's easy, now let's add an element that follows our animated foreground line.

Animating an svg circle along a path with Framer Motion

Add two new circle elements to your svg, make sure they're motion elements.

<motion.svg width="500" height="50" viewBox="0 0 500 30"> <path stroke="white" strokeWidth="10" strokeLinecap="round" d="M15 15 H490" /> <motion.path d="M15 15 H490" stroke="#1f88eb" strokeWidth="10" strokeLinecap="round" initial={{ pathLength: 0 }} animate={{ pathLength: progress / 100 }} transition={transition} /> <motion.circle cx={15} cy={15} r="15" fill="#1f88eb" /> <motion.circle cx={15} cy={15} r="5" fill="white" /> </motion.svg>

You should see two concentric circles at the start of your progress line. Let's get working on tracking the progress of our animation.

To do this, we'll need to assign some references to each DOM element.

At the top of your component add the following:

const pathRefForeground = useRef(null); const progressLength = useMotionValue(0); const progressX = useMotionValue(0); const progressY = useMotionValue(0); useEffect(() => { const pathElementForeground = pathRefForeground.current; }, []);

Then update your imports:

import { motion, useMotionValue } from "framer-motion"; import { useRef, useEffect } from "react";

Update the foreground path's ref property to the pathRefForeground variable we just created and add the progressLength motion listener to the pathLength prop of the same element.

<motion.path d="M15 15 H490" stroke="#1f88eb" strokeWidth="10" strokeLinecap="round" ref={pathRefForeground} pathLength={progressLength} initial={{ pathLength: 0 }} animate={{ pathLength: progress / 100 }} transition={transition} />

Getting the reference to the element allows us to get useful information about it's current path length.

While the motion listener allows us to assign a callback that will be triggered whenever the progressLength updates.

You can read more about that here.

With that in mind, update cy and cx of our progress circles to the motion listeners progressY and progressX.

<motion.circle cx={progressX} cy={progressY} r="15" fill="#1f88eb" /> <motion.circle cx={progressX} cy={progressY} r="5" fill="white" />

This allows us to set the center of the circle on the fly. In this case, whenever the progressLength updates.

Here's where we should be so far, with our circle still motionless.

Motionless circle

import { motion, useMotionValue } from "framer-motion"; import { useRef, useEffect } from "react"; export default function App() { const pathRefForeground = useRef(null); const progressLength = useMotionValue(0); const progressX = useMotionValue(0); const progressY = useMotionValue(0); useEffect(() => { const pathElementForeground = pathRefForeground.current; }, []); const transition = { repeat: Infinity, bounce: 0.75, type: "spring", duration: 2 }; const progress = 50; return ( <div className="App" style={{ minHeight: 500, display: "flex", flexDirection: "column", justifyContent: "center", alignItems: "center", background: "#d9eefd" }} > <motion.svg width="500" height="50" viewBox="0 0 500 30"> <path stroke="white" strokeWidth="10" strokeLinecap="round" d="M15 15 H490" /> <motion.path d="M15 15 H490" stroke="#1f88eb" strokeWidth="10" strokeLinecap="round" ref={pathRefForeground} pathLength={progressLength} initial={{ pathLength: 0 }} animate={{ pathLength: progress / 100 }} transition={transition} /> <motion.circle cx={progressX} cy={progressY} r="15" fill="#1f88eb" /> <motion.circle cx={progressX} cy={progressY} r="5" fill="white" /> </motion.svg> </div> ); }

Let's add some movement to the circle element

In your useEffect method, add the following under the pathElementForeground declaration:

const totalPathLength = pathElementForeground.getTotalLength(); const initialProgress = progressLength.get(); const initialCoords = pathElementForeground.getPointAtLength( initialProgress * totalPathLength ); progressX.set(initialCoords.x); progressY.set(initialCoords.y);

This starts by getting the total length possible of our progress path. Then, we get the starting path length from the motion listener. This is 0 to start.

Now we multiply the starting length (0) with the total path length to get the X and Y coordinates of our circles.

In this case, it should place our circles at exactly the start point of the path.

Doing it this way allows us to start our circles at any distance from the start by modifying the motion listener's initial value.

Finally, let's listen to the progressLength's updates and update the center of our circles whenever it changes.

Add the following to your useEffect:

const unsubscribe = progressLength.onChange((latestPercent) => { const latestPathProgress = pathElementForeground.getPointAtLength( latestPercent * totalPathLength ); progressX.set(latestPathProgress.x); progressY.set(latestPathProgress.y); }); return unsubscribe;

We're getting the X and Y coordinates of the latest path endpoint and assigning it to our circle center every time it updates.

Then we return the unsubscribe function as to prevent a memory leak in our application. The docs have more about this.

Your final useEffect method should contain the following:

useEffect(() => { const pathElementForeground = pathRefForeground.current; const totalPathLength = pathElementForeground.getTotalLength(); const initialProgress = progressLength.get(); const initialCoords = pathElementForeground.getPointAtLength( initialProgress * totalPathLength ); progressX.set(initialCoords.x); progressY.set(initialCoords.y); const unsubscribe = progressLength.onChange((latestPercent) => { const latestPathProgress = pathElementForeground.getPointAtLength( latestPercent * totalPathLength ); progressX.set(latestPathProgress.x); progressY.set(latestPathProgress.y); }); return unsubscribe; }, []);

That's it! Everything should be now working as shown in the demo.

Project output

Here's the final code:

import { motion, useMotionValue } from "framer-motion"; import { useRef, useEffect } from "react"; export default function App() { const pathRefForeground = useRef(null); const progressLength = useMotionValue(0); const progressX = useMotionValue(0); const progressY = useMotionValue(0); useEffect(() => { const pathElementForeground = pathRefForeground.current; const totalPathLength = pathElementForeground.getTotalLength(); const initialProgress = progressLength.get(); const initialCoords = pathElementForeground.getPointAtLength( initialProgress * totalPathLength ); progressX.set(initialCoords.x); progressY.set(initialCoords.y); const unsubscribe = progressLength.onChange((latestPercent) => { const latestPathProgress = pathElementForeground.getPointAtLength( latestPercent * totalPathLength ); progressX.set(latestPathProgress.x); progressY.set(latestPathProgress.y); }); return unsubscribe; }, []); const transition = { repeat: Infinity, bounce: 0.75, type: "spring", duration: 2 }; const progress = 50; return ( <div className="App" style={{ minHeight: 500, display: "flex", flexDirection: "column", justifyContent: "center", alignItems: "center", background: "#d9eefd" }} > <motion.svg width="500" height="50" viewBox="0 0 500 30"> <path stroke="white" strokeWidth="10" strokeLinecap="round" d="M15 15 H490" /> <motion.path d="M15 15 H490" stroke="#1f88eb" strokeWidth="10" strokeLinecap="round" ref={pathRefForeground} pathLength={progressLength} initial={{ pathLength: 0 }} animate={{ pathLength: progress / 100 }} transition={transition} /> <motion.circle cx={progressX} cy={progressY} r="15" fill="#1f88eb" /> <motion.circle cx={progressX} cy={progressY} r="5" fill="white" /> </motion.svg> </div> ); }