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:
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:
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.
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.
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.
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> ); }