image of an outdoors path going up a hill
Photo by Karsten Würth on Unsplash

What is CSS Motion Path?

Animating elements along a custom path has never been easier!

8 min read

Quite simply, CSS Motion Path is a CSS property used to animate an element along a path. This path can be anything - one you've created yourself or taken from an existing design. The element you want to animate can also be anything - HTML or even another SVG.

In the past, you needed complicated math or a even Javascript library to handle animating elements in this way. Now it can be done with a few lines of CSS. Let’s find out how!

A note about responsive CSS motion paths. At the time of this writing, depending on the effect you’re looking to achieve, it can be difficult to have fully responsive CSS motion paths, so we won’t be covering a completely responsive solution. Articles like this one go into detail about ways you could handle it, but it’s beyond the scope of these examples.

Example #1

Let's start with a very basic example and animate an element along an SVG path.

First we need a path. In this example we’ll use one that's a curved “S” shape, taken from the SVG below.


<svg width="484" height="244" viewBox="0 0 484 244" fill="none" xmlns="http://www.w3.org/2000/svg">
	<path d="M2 122C2 55.7258 55.7258 2 122 2C188.274 2 242 55.7258 242 122C242 188.274 295.726 242 362 242C428.274 242 482 188.274 482 122" stroke="black" stroke-width="3"/>
</svg>

If you reference the sandbox demo, you'll notice that background SVGs are being used differently. Depending on your tooling setup, you may need to inline the SVGs for them to work correctly as a background image. Tools like this can help.

Next add a div. The SVG above is set as the background for this div to help us visually understand what’s happening. It serves as a guide and removing it doesn't impact the actual animation of the element.


<div className="example-1" />


.example-1 {
  height: 263px;
  width: 100%;
  max-width: 484px;
  background: url('./assets/zigzag.svg') no-repeat;
}

Now that we have an SVG on the screen, we’ll make a square move along that same path. Add a :before pseudo class to the curved-path div.


.example-1:before {
  content: '';
  display: block;
  height: 50px;
  width: 50px;
  background: orange;
  offset-path: path("M2 122C2 55.7258 55.7258 2 122 2C188.274 2 242 55.7258 242 122C242 188.274 295.726 242 362 242C428.274 242 482 188.274 482 122");
  animation: example1 4s infinite ease forwards;
}

Notice the use of the offset-path property. The value here is the same path as the background SVG. This is what defines the trajectory of the square and tells it where to move and it's the main "trick" behind this technique.

Finally, a CSS animation keyframe is used to actually move the square. Here we’re using the offset-distance property to define how far the shape should travel.


@keyframes example1 {
  from {
    offset-distance: 0%;
  }

  to {
    offset-distance: 100%;
  }
}

The SVG Path Visualizer tool is really helpful to break down the syntax of any SVG path, especially if you’re making a custom one.

Example #2

Now that you understand how CSS motion path works, let’s take what we learned and do something a little more interesting.

In this example, we’ll animate some text along a path.

Just like in the previous example, we’ll start with an SVG. This one is a slightly curved line with an orange stroke.


<svg width="780" height="150" viewBox="0 0 780 150" fill="none" xmlns="http://www.w3.org/2000/svg">
  <path d="M40 109.98C129.704 60.7615 219.407 35.03 285.972 40.7985C352.537 46.5671 395.963 83.8356 467.778 96.7013C539.593 109.567 639.796 98.03 740 86.493" stroke="#FF6900" stroke-width="80" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Next, create a div to be used as the container and set the above SVG as the background.


<div className="example-2"></div>


.example-2 {
  height: 150px;
  width: 91%;
  max-width: 100%;
  overflow: hidden;
  background: url('./assets/curved-fill.svg') no-repeat;
}

Now we have some CSS to position the text and define a path using offset-path.


.example-2 span {
  position: absolute;
  font-size: 30px;
  font-weight: bold;
  color: white;
  opacity: 0;
  offset-path: path(
    "M40 109.98C129.704 60.7615 219.407 35.03 285.972 40.7985C352.537 46.5671 395.963 83.8356 467.778 96.7013C539.593 109.567 639.796 98.03 740 86.493"
  );
  animation: example2 6s infinite linear forwards;
}

The opacity: 0 hides the letters before they start animating.

The last part of the CSS is adding a keyframe animation.

It’s similar to the first example, but there are some additional steps where we adjust the opacity for a slight fade in effect at the beginning and fade out at the end.


@keyframes example2 {
  0% {
    opacity: 0;
    offset-distance: 0%;
  }

  10% {
    opacity: 1;
  }

  90% {
    opacity: 1;
  }

  100% {
    opacity: 0;
    offset-distance: 100%;
  }
}

Now that we’ve added the CSS, we'll need some text. But we can’t just add a regular text string.

The reason why is because each letter of the text should animate along the path individually. If you try to use a whole string of words wrapped in a single element, it will move as a single element and not as a series of letters.

What we need then is a function to split text into individual span elements, each with a slightly incrementing animation delay to closely follow the previous letter.

Start by making a new function named splitTextCharacters.

This function should take a string, split it into individual characters, reverse those characters and map over them.


const splitTextCharacters = (text) => text.split('').reverse().map((char, i) => {
  return <span key={i} style={{ animationDelay: `${i * 0.10}s`}}>{char}</span>;
});

Each character is then wrapped in a span and assigned an animationDelay incrementing by 0.10s each.

You’ll notice that the spacing between letters is not perfect. Depending on the text you want to show for this effect some fine tuning would be necessary.

Now use the function by updating the HTML and passing some text.


<div className="example-2">
  {splitTextCharacters('A longer text string')}
</div>

Example #3

Now we’ll take everything we learned and use it to move a car icon along a curved path based on the user scrolling the page.

A similar effect can be seen on the homepage of Joyride Sweets.

This time we’ll start by setting up a React component that returns some HTML.


import { useState, useRef, useEffect } from 'react';

export const Example3 = () => {
  const bottomRef = useRef();

  return (
		<div className="example-3">
		  <div className="wave">
		    <span className="car" />
		  </div>
		  <div className="bottom" ref={bottomRef}>
		    <h2>Scroll up</h2>
		  </div>
		</div>
	);
}

The bottom div serves as a target for the scrolling behavior and based on its visibility, controls when we start moving the shape. We’re able to do this with a React useRef and the Intersection Observer API. More on that in a minute.

Check out The Intersection Observer API: Practical Examples for a more detailed intro to this topic. We’ll assume you have at least some familiarity with it.

Next let’s add the CSS.

The first step, just like the last two examples, is to add an SVG path and set it as the background.


<svg width="864" height="200" viewBox="0 0 864 200" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 200V4.69619C36.0359 25.3876 72.0717 46.079 102 38.2884C131.928 30.4979 155.749 -5.77447 177.6 0.790109C199.451 7.35469 219.332 56.7563 246 60.9437C272.668 65.1311 306.122 24.1044 340.2 10.9459C374.278 -2.21259 408.979 12.4971 440.4 25.789C471.821 39.0809 499.962 50.9549 525 47.663C550.038 44.3711 571.973 25.9134 598.8 25.0078C625.627 24.1022 657.346 40.7488 685.8 39.0697C714.254 37.3905 739.444 17.3855 768.6 8.60226C797.756 -0.180952 830.878 2.25762 864 4.69619V200H0Z" fill="#00d084"/>
</svg>


.example-3 .wave {
  height: 206px;
  width: 100%;
  max-width: 864px;
  background: url('./assets/scroll-wave.svg') no-repeat;
  background-size: cover;
  position: relative;
}

Then we have a containing element for this example. The top margin will allow a bit of room for scrolling.


.example-3 {
  margin: 70vh auto 0;
  width: 100%;
  max-width: 1440px;
  text-align: center;
}

Next we’ll add the car icon and the path it should follow.


.example-3 .car {
  position: absolute;
  left: 0;
  right: 0;
  top: 48%;
  height: 50px;
  width: 100px;
  background: url('./assets/car.svg') no-repeat;
  offset-path: path(
    "M0 2C36.0359 25.3876 72.0717 46.079 102 38.2884C131.928 30.4979 155.749 -5.77447 177.6 0.790109C199.451 7.35469 219.332 56.7563 246 60.9437C272.668 65.1311 306.122 24.1044 340.2 10.9459C374.278 -2.21259 408.979 12.4971 440.4 25.789C471.821 39.0809 499.962 50.9549 525 47.663C550.038 44.3711 571.973 25.9134 598.8 25.0078C625.627 24.1022 657.346 40.7488 685.8 39.0697C714.254 37.3905 739.444 17.3855 768.6 8.60226C797.756 -0.180952 830.878 2.25762 864 4"
  );
  transition: offset-distance 0.4s ease;
}

Finally, there’s some space beneath everything else.


.example-3 .bottom {
  background: #00d084;
  padding: 200px 0;
}

Notice how there’s no animation property this time. What we’ll do instead is set the transition-distance property based on the visibility of the bottom element.

This is all the CSS we’ll need.

Back in the component, add a new state value. This state tracks the offset-distance to be applied and will update dynamically. We’ll default it to 5 so that the car icon appears just off the left edge of the screen.


export const Example3 = () => {
  ...
  const [offsetValue, setOffsetValue] = useState(5);

  return (...);
}

Within a useEffect that runs when the component mounts, set up the observer.


useEffect(() => {
  const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      const visiblePercentage = entry.intersectionRatio * 100;

      if (visiblePercentage >= 5 && visiblePercentage <= 95) {
        setOffsetValue(visiblePercentage.toFixed(2));
      }
    });
  }, {});
}, []);

The observer's callback function calculates the percentage of the bottomRef visibility. If this percentage is between 5% and 95%, it updates the setOffsetValue state.

A few options should be passed to the observer. It’s configured with the viewport as the root by setting root: null and uses a dynamic set of thresholds provided by getThresholds.


const observer = new IntersectionObserver((entries) => {
  ...
}, {
  root: null,
  threshold: getThresholds(),
});

getThresholds outputs an array of about 45 threshold numbers that, when reached, update the offset-distance of the element.


function getThresholds() {
  let thresholds = [];

  for (let i = 0.1; i <= 1.0; i += 0.02) {
    thresholds.push(i);
  }

  return thresholds;
}

Experiment with these values. If they’re spaced too far apart, you may notice the animation start to become choppy, especially when scrolling slowly.

Finally, set the observer to start tracking the bottomRef visibility, triggering the callback at each of the thresholds we defined.


useEffect(() => {
  ...
  observer.observe(bottomRef.current);
}, []);

Now use the state value we’re calculating and add an inline style property for the icon.


<span className="car" style={{ offsetDistance: `${offsetValue}%`}} />

Summary

In this article you learned how to animate an element along a path using CSS Motion Path. The three examples we covered should hopefully show you that there's a lot of ways to leverage this ability.

Animations like this should always be used sparingly, but knowing this is possible with just a few lines of code is certainly helpful!