There was a discussion recently on the Animation at Work Slack: how could you make a CSS motion path responsive? What techniques would be work? This got me thinking.
A CSS motion path allows us to animate elements along custom user-defined paths. Those paths follow the same structure as SVG paths. We define a path for an element using offset-path
.
.block {
offset-path: path('M20,20 C20,100 200,0 200,100');
}
These values appear relative at first and they would be if we were using SVG. But, when used in an offset-path
, they behave like px units. This is exactly the problem. Pixel units aren’t really responsive. This path won’t flex as the element it is in gets smaller or larger. Let’s figure this out.
To set the stage, the offset-distance
property dictates where an element should be on that path:
Not only can we define the distance an element is along a path, but we can also define an element’s rotation with offset-rotate. The default value is auto which results in our element following the path. Check out the property’s almanac article for more values.
To animate an element along the path, we animate the offset-distance
:
OK, that catches up to speed on moving elements along a path. Now we have to answer…
Can we make responsive paths?
The sticking point with CSS motion paths is the hardcoded nature. It’s not flexible. We are stuck hardcoding paths for particular dimensions and viewport sizes. A path that animates an element 600px, will animate that element 600px regardless of whether the viewport is 300px or 3440px wide.
This differs from what we are familiar with when using SVG paths. They will scale with the size of the SVG viewbox.
Try resizing this next demo below and you’ll see:
- The SVG will scale with the viewport size as will the contained path.
- The offset-path does not scale and the element goes off course.
This could be okay for simpler paths. But once our paths become more complicated, it will be hard to maintain. Especially if we wish to use paths we’ve created in vector drawing applications.
For example, consider the path we worked with earlier:
.element {
--path: 'M20,20 C20,100 200,0 200,100';
offset-path: path(var(--path));
}
To scale that up to a different container size, we would need to work out the path ourselves, then apply that path at different breakpoints. But even with this “simple” path, is it a case of multiplying all the path values? Will that give us the right scaling?
@media(min-width: 768px) {
.element {
--path: 'M40,40 C40,200 400,0 400,200'; // ????
}
}
A more complex path such as one drawn in a vector application is going to be trickier to maintain. It will need the developer to open the application, rescale the path, export it, and integrate it with the CSS. This will need to happen for all container size variations. It’s not the worst solution, but it does require a level of maintenance that we might not want to get ourselves into.
.element {
--path: 'M40,228.75L55.729166666666664,197.29166666666666C71.45833333333333,165.83333333333334,102.91666666666667,102.91666666666667,134.375,102.91666666666667C165.83333333333334,102.91666666666667,197.29166666666666,165.83333333333334,228.75,228.75C260.2083333333333,291.6666666666667,291.6666666666667,354.5833333333333,323.125,354.5833333333333C354.5833333333333,354.5833333333333,386.0416666666667,291.6666666666667,401.7708333333333,260.2083333333333L417.5,228.75';
offset-path: path(var(--path));
}
@media(min-width: 768px) {
.element {
--path: 'M40,223.875L55.322916666666664,193.22916666666666C70.64583333333333,162.58333333333334,101.29166666666667,101.29166666666667,131.9375,101.29166666666667C162.58333333333334,101.29166666666667,193.22916666666666,162.58333333333334,223.875,223.875C254.52083333333334,285.1666666666667,285.1666666666667,346.4583333333333,315.8125,346.4583333333333C346.4583333333333,346.4583333333333,377.1041666666667,285.1666666666667,392.4270833333333,254.52083333333334L407.75,223.875';
}
}
@media(min-width: 992px) {
.element {
--path: 'M40,221.625L55.135416666666664,191.35416666666666C70.27083333333333,161.08333333333334,100.54166666666667,100.54166666666667,130.8125,100.54166666666667C161.08333333333334,100.54166666666667,191.35416666666666,161.08333333333334,221.625,221.625C251.89583333333334,282.1666666666667,282.1666666666667,342.7083333333333,312.4375,342.7083333333333C342.7083333333333,342.7083333333333,372.9791666666667,282.1666666666667,388.1145833333333,251.89583333333334L403.25,221.625';
}
}
It feels like a JavaScript solution makes sense here. GreenSock is my first thought because its MotionPath plugin can scale SVG paths. But what if we want to animate outside of an SVG? Could we write a function that scales the paths for us? We could but it won’t be straightforward.
Trying different approaches
What tool allows us to define a path in some way without the mental overhead? A charting library! Something like D3.js allows us to pass in a set of coordinates and receive a generated path string. We can tailor that string to our needs with different curves, sizing, etc.
With a little tinkering, we can create a function that scales a path based on a defined coordinate system:
This definitely works, but it’s also less than ideal because it’s unlikely we are going to be declaring SVG paths using sets of coordinates. What we want to do is take a path straight out of a vector drawing application, optimize it, and drop it on a page. That way, we can invoke some JavaScript function and let that do the heavy lifting.
So that’s exactly what we are going to do.
First, we need to create a path. This one was thrown together quickly in Inkscape. Other vector drawing tools are available.

Next, let’s optimize the SVG. After saving the SVG file, we’ll run it through Jake Archibald’s brilliant SVGOMG tool. That gives us something along these lines:
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 79.375 79.375" height="300" width="300"><path d="M10.362 18.996s-6.046 21.453 1.47 25.329c10.158 5.238 18.033-21.308 29.039-18.23 13.125 3.672 18.325 36.55 18.325 36.55l12.031-47.544" fill="none" stroke="#000" stroke-width=".265"/></svg>
The parts we’re interested are path
and viewBox
.
Expanding the JavaScript solution
Now we can create a JavaScript function to handle the rest. Earlier, we created a function that takes a set of data points and converts them into a scalable SVG path. But now we want to take that a step further and take the path string and work out the data set. This way our users never have to worry about trying to convert their paths into data sets.
There is one caveat to our function: Besides the path string, we also need some bounds by which to scale the path against. These bounds are likely to be the third and fourth values of the viewBox attribute in our optimized SVG.
const path =
"M10.362 18.996s-6.046 21.453 1.47 25.329c10.158 5.238 18.033-21.308 29.039-18.23 13.125 3.672 18.325 36.55 18.325 36.55l12.031-47.544";
const height = 79.375 // equivalent to viewbox y2
const width = 79.375 // equivalent to viewbox x2
const motionPath = new ResponsiveMotionPath({
height,
width,
path,
});
We won’t go through this function line-by-line. You can check it out in the demo! But we will highlight the important steps that make this possible.
First, we’re converting a path string into a data set
The biggest part of making this possible is being able to read the path segments. This is totally possible, thanks to the SVGGeometryElement API. We start by creating an SVG element with a path and assigning the path string to its d
attribute.
// To convert the path data to points, we need an SVG path element.
const svgContainer = document.createElement('div');
// To create one though, a quick way is to use innerHTML
svgContainer.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg">
<path d="${path}" stroke-width="${strokeWidth}"/>
</svg>`;
const pathElement = svgContainer.querySelector('path');
Then we can use the SVGGeometryElement API on that path element. All we need to do is iterate over the total length of the path and return the point at each length of the path.
convertPathToData = path => {
// To convert the path data to points, we need an SVG path element.
const svgContainer = document.createElement('div');
// To create one though, a quick way is to use innerHTML
svgContainer.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg">
<path d="${path}"/>
</svg>`;
const pathElement = svgContainer.querySelector('path');
// Now to gather up the path points.
const DATA = [];
// Iterate over the total length of the path pushing the x and y into
// a data set for d3 to handle 👍
for (let p = 0; p < pathElement.getTotalLength(); p++) {
const { x, y } = pathElement.getPointAtLength(p);
DATA.push([x, y]);
}
return DATA;
}
Next, we generate scaling ratios
Remember how we said we’d need some bounds likely defined by the viewBox
? This is why. We need some way to calculate a ratio of the motion path against its container. This ratio will be equal to that of the path against the SVG viewBox
. We will then use these with D3.js scales.
We have two functions: one to grab the largest x
and y
values, and another to calculate the ratios in relation to the viewBox
.
getMaximums = data => {
const X_POINTS = data.map(point => point[0])
const Y_POINTS = data.map(point => point[1])
return [
Math.max(...X_POINTS), // x2
Math.max(...Y_POINTS), // y2
]
}
getRatios = (maxs, width, height) => [maxs[0] / width, maxs[1] / height]
Now we need to generate the path
The last piece of the puzzle is to actually generate the path for our element. This is where D3.js actually comes into play. Don’t worry if you haven’t used it before because we’re only using a couple of functions from it. Specifically, we are going to use D3 to generate a path string with the data set we generated earlier.
To create a line with our data set, we do this:
d3.line()(data); // M10.362000465393066,18.996000289916992L10.107386589050293, etc.
The issue is that those points aren’t scaled to our container. The cool thing with D3 is that it provides the ability to create scales. These act as interpolation functions. See where this is going? We can write one set of coordinates and then have D3 recalculate the path. We can do this based on our container size using the ratios we generated.
For example, here’s the scale for our x
coordinates:
const xScale = d3
.scaleLinear()
.domain([
0,
maxWidth,
])
.range([0, width * widthRatio]);
The domain is from 0 to our highest x
value. The range in most cases will go from 0 to container width multiplied by our width ratio.
There are times where our range may differ and we need to scale it. This is when the aspect ratio of our container doesn’t match that of our path. For example, consider a path in an SVG with a viewBox
of 0 0 100 200
. That’s an aspect ratio of 1:2. But if we then draw this in a container that has a height and width of 20vmin, the aspect ratio of the container is 1:1. We need to pad the width range to keep the path centered and maintain the aspect ratio.
What we can do in these cases is calculate an offset so that our path will still be centered in our container.
const widthRatio = (height - width) / height
const widthOffset = (ratio * containerWidth) / 2
const xScale = d3
.scaleLinear()
.domain([0, maxWidth])
.range([widthOffset, containerWidth * widthRatio - widthOffset])
Once we have two scales, we can map our data points using the scales and generate a new line.
const SCALED_POINTS = data.map(POINT => [
xScale(POINT[0]),
yScale(POINT[1]),
]);
d3.line()(SCALED_POINTS); // Scaled path string that is scaled to our container
We can apply that path to our element by passing it inline via a CSS property 👍
ELEMENT.style.setProperty('--path', `"${newPath}"`);
Then it’s our responsibility to decide when we want to generate and apply a new scaled path. Here’s one possible solution:
const setPath = () => {
const scaledPath = responsivePath.generatePath(
CONTAINER.offsetWidth,
CONTAINER.offsetHeight
)
ELEMENT.style.setProperty('--path', `"${scaledPath}"`)
}
const SizeObserver = new ResizeObserver(setPath)
SizeObserver.observe(CONTAINER)
This demo (viewed best in full screen) shows three versions of the element using a motion path. The paths are present to easier see the scaling. The first version is the unscaled SVG. The second is a scaling container illustrating how the path doesn’t scale. The third is using our JavaScript solution to scale the path.
Phew, we did it!
This was a really cool challenge and I definitely learned a bunch from it! Here’s a couple of demos using the solution.
It should work as a proof of concept and looks promising! Feel free to drop your own optimized SVG files into this demo to try them out! — it should catch most aspect ratios.
I’ve created a package named “Meanderer” on GitHub and npm. You can also pull it down with unpkg CDN to play with it in CodePen, if you want to try it out.
I look forward to seeing where this might go and hope we might see some native way of handling this in the future. 🙏
Great through article!
I too was fascinated with path animation, and had
created a tiny script (many years ago) which automates movement on a path:
https://github.com/yairEO/pathAnimator
Thanks vsync!
Ooo, checked it out, that’s cool. It is a fascinating subject. Quite interested to see where it goes in CSS.
If there will be some kind of relative positioning support in the future maybe or something else.
I wonder if we’ll ever get
%
units within thepath()
function like we get for thepolygon()
function that we can use with stuff likeclip-path
? That makesclip-path
really flexible and having that same ability here would make this way simpler.Totally! It would be a really cool feature if we did. The only thing with
clip-path: polygon()
is curve support. Any form of relative syntax would require some way of defining curve handles too. Think that’s where it maybe gets a little tricky. Creating curve syntax by hand isn’t easy ha. And if we don’t require curves, most things can be handled with chainedtranslate
.How would you foresee writing curves by hand? Or maybe there would be a way to define some kind of “out of the box” curving, like with
animation-timing-function
.For example, here’s my path
--path: path(0 0, 50% 0, 100% 50%)
. Now I want it to use a basic curve,offset-path-curve: normal || none || other options
.I last commented here why I think this could break the parser.
Re-sampling the path as a polyline is big solution for a small problem, and it works suboptimal at best. The resulting motion path has information losses, as it only approximates the original path, and at the same time it could increase the size of the path data dramatically: In you example, the path string goes from 137 characters for 9 vertices to 5718 characters for 155 vertices.
A much simpler (in terms of resources needed) and better way (in terms of lossless transformation) would be the use of a library that handles rewriting the path data for arbitrary transforms such that each path command is preserved in structure, while only the control point numbers are adapted.
I have written one such library as part of an unpublished project myself, and there are others around. For example a quick search revealed svgpath, which would be perfectly capable of handling all your needs.
As for finding the transformation needed to fit your path to the container, your solution looks a bit improvised. The correct algorithm is described in the spec. Here’s an implementation where you simply throw in all the attribute strings your source SVG has and get out the correct scale and translate transforms.
That may be, but, it is a solution.
It’s a proof of concept. It was never stated to be perfect, nor optimal. It’s exploring what’s possible. It’s not being advocated as something to go and use on your production websites.
Scaling SVG is not the issue. That can already be done in the browser. Scaling the path string is the issue. We aren’t using SVG. But
offset-path
uses the SVG path syntax. Scaling is a solution, but what about scenarios where we don’t want to scale the elements that are moving.Either way, I look forward to seeing your solution.
Here it is: https://github.com/ccprog/pathfit (I’ll enhance this further and publish it to npm at some point.)
I am sorry my comments about SVG scaling were unclear. As you stated, SVGs have a sizing algorithm to make them responsive. To emulate this responsiveness for the use with CSS
path()
, it makes sense to re-create just this algorithm to get from the viewBox (or intrinsic size) of a source SVG the path data are from, to the width and height of the container element you apply the motionpath to.As a plus, if you stick with the algorithm form the spec, you get a mechanism to describe where and how to place your motionpath if the aspect ratio of the original SVG source and the container do not fit, by using the syntax of the
<svg>
elementpreserveAspectRatio
attribute.That’s great! Thanks for sharing.
I’m all for finding a better solution. As I said before, this was merely a proof of concept to explore what’s possible and start conversations like this one. My intention is to find a good solution that people can use whilst outsourcing that path generation piece elsewhere.
At the time of looking, I couldn’t find a browser ready package that would do something close to what I was looking for.
svgpath
can do what we want. But I’ve not had luck bundling it. Ideally, a package that does this and supports tree-shaking would be fantastic. That way it would be a simple case ofimport { scale } from 'svg-path-transformer-package'
. That’s the only part required. The micro-library can handle working out those scales or whether the transformation is required at all. In cases where we don’t mind if the moving element is scaled, we can simply scale the entire container accordingly.Holy wow, Jhey – that was exactly the R&D process I was looking for in accomplishing what you did, and you saved me many hours of work. Thank you so much for the detailed write-up – I learned a lot and appreciate your sharing your journey in how you went about doing responsive path following. Cheers man!