Unfortunately, clip-path: path() is Still a No-Go

Avatar of Ana Tudor
Ana Tudor on

I was extremely excited when I first heard that clip-path: path() was coming to Firefox. Just imagine being able to easily code a breathing box like the one below with just one HTML element and very little CSS without needing SVG or a huge list of points inside the polygon function!

Chris was excited about the initial implementation, too.

How fun would this be:

Animated gif. Shows a square breathing in and out - its waistline smoothly contracts and then expands.
Breathing box.

I decided to give it a try. I went on CodePen, dropped a <div> in the HTML panel, gave it dimensions in viewport units so that it scales nicely, added a background so that I could see it. Then I went on MDN to check out some usage examples… and my fluffy cloud of a dream began to crash!

Note that clip-path: path() only works in Firefox 63-70 with the layout.css.clip-path-path.enabled flag set to true in about:config and in Firefox 71+ without needing to enable any flag. (Source: MDN.)

These were the examples I found:

path('M0 200L0 110A110 90 0 0 1 240 100L 200 340z')
path('M.5 1C.5 1 0 .7 0 .3A.25 .25 1 1 1 .5 .3 .25 .25 1 1 1 1 .3C1 .7 .5 1 .5 1Z')

What are those coordinates? The sad answer is pixel values! Those are used because the path() function takes an SVG <path> string as an argument which — like the value of the SVG d attribute on a <path> element — only contains one kind of coordinate value: unit-less pixels. In the SVG case, these pixels scale with the viewBox of the <svg> element but they don’t scale at all inside the CSS path() function!

This means the element always gets clipped to the same fixed area if we have a responsive element with a path() value for the clip-path property. For example, consider a square .box whose edge length is 35vw. We clip it to a heart using the path() function:

clip-path: path('M256 203C150 309 150 309 44 203 15 174 15 126 44 97 73 68 121 68 150 97 179 68 227 68 256 97 285 126 285 174 256 203')

This heart shape stays the same size while the dimensions of our actual .box element changes with the viewport:

Animated gif. Shows how the heart clipped using a fixed pixel path() doesn't fit within the element's bounding rectangle when its viewport-depending size goes down at small screen sizes.
The issue with a fixed pixel path().

This is bad news here in 2020, where responsive design is the standard, not the exception. Save for the odd case where the element we want to clip actually has a fixed pixel size, the path() function is completely useless! We’re still better off using an actual SVG today, or even a polygon() approximation value for clip-path. In short, path() is still in need of improvement, despite getting off the ground.

Amelia Bellamy-Royds has suggested two possibilities here:

Option 1: Allow calc() values/units inside path data. This would probably be done while extending SVG path syntax in general.

Option 2: Specify viewBox in clip-path declaration, scale path to fit.

I personally prefer the first option. The only advantage the second one offers over using SVG is the fact that we don’t have to include an actual SVG. That said, including an actual SVG is always going to have better support.

The first option, however, could be a huge improvement over using SVG — at least enough of an improvement to justify using clip-path on an HTML element instead of including an SVG inside it. Let’s consider the breathing box at the top of this post. Using SVG, we have the following markup:

<svg viewBox='-75 -50 150 100'>
  <path/>
</svg>

Note that the viewBox is set such that the 0,0 point is dead in the middle. This means we’ve got to make the coordinates of the top-left corner (i.e. first two viewBox values) equal to minus half the viewBox dimensions (i.e. the last two viewBox values).

In SCSS, we set the edge length ($l) of the initial square box as the smallest viewBox dimension (which is the smallest of the last two values). This is 100 in our case.

We start the path from the top-left corner of our square box. This means a move to (M) command to this point, with coordinates that are both equal to minus half the length of the edge.

We then go down to the bottom-left corner. This requires drawing a vertical line with a length that equals an edge length ($l) and goes down, in the positive direction of the y axis. So, we’ll use the v command.

Next, we go to the bottom-right corner. We’ll draw a horizontal line with a length that equals an edge length ($l) and goes right, in the positive direction of the x axis. We’ll use the h command to make that happen.

Going to the top-right corner means drawing another vertical line of with a length equal to the edge length ($l), so we will use the v command again — only this time, the difference is the fact that we go in the opposite direction of the y axis, meaning we use the same coordinates, but with a minus sign.

Putting it all together, we have the SCSS that allows us to create the initial square box:

.box {
  d: path('M#{-.5*$l},#{-.5*$l} v#{$l} h#{$l} v#{-$l}');
  fill: darkorange
}

The generated CSS (where $l is replaced with 100) looks like this:

.box {
  d: path('M-50,-50 v100 h100 v-100');
  fill: darkorange;
}

The result can be seen in the interactive demo below where hovering a part of the path data highlights the corresponding part in the resulting SVG and the other way around:

See the Pen by thebabydino (@thebabydino) on CodePen.

However, if we want the lateral edges to breathe, we can’t use straight lines. Let’s replace those with quadratic Bézier (q) ones. The end point remains the same, which is one edge length down along the same vertical line. We travel by 0,#{$l} to get there.

But what about the control point we need to specify before that? We place the point to be vertically midway between the start and end points, meaning we go down to it by half of we travel to get to the end point.

And let’s say that, horizontally, we place it by a quarter of an edge length to the side in one direction or the other. If we want the lines to protrude to widen the box or squeeze them in to narrow it, we need to do something like this:

d: path('M#{-.5*$l},#{-.5*$l} 
         q#{-.25*$l},#{.5*$l} 0,#{$l} 
         h#{$l} 
         v#{-$l}'); /* swollen box */

d: path('M#{-.5*$l},#{-.5*$l} 
         q#{.25*$l},#{.5*$l} 0,#{$l} 
         h#{$l} 
         v#{-$l}'); /* squished box */

This compiles to the following CSS:

d: path('M-50,-50 
         q-25,50 0,100 
         h100 
         v-100'); /* swollen box */

d: path('M-50,-50 
         q25,50 0,100 
         h100 
         v-100'); /* squished box */

The interactive demo below shows how this path works. You can hover over path data components to see them highlighted on the SVG graphic. You can also toggle between the swollen and squished versions.

See the Pen by thebabydino (@thebabydino) on CodePen.

This is only the left edge. We need to do the same thing for the right edge as well. The difference here is that we’re going from the bottom-right corner to the top-right corner instead, which is up (in the negative direction of the y axis). We’ll place the control point outside the box to get the wide ox effect, which also means placing it to the right of its endpoints (in the positive direction of the x axis). Meanwhile, we’ll place the control point inside to get the narrow box effect, which means placing it to the left of its endpoints (in the negative direction of the x axis).

d: path('M#{-.5*$l},#{-.5*$l} 
         q#{-.25*$l},#{.5*$l} 0,#{$l} 
         h#{$l} 
         q#{.25*$l},#{-.5*$l} 0,#{-$l}'); /* swollen box */

d: path('M#{-.5*$l},#{-.5*$l} 
         q#{.25*$l},#{.5*$l} 0,#{$l} 
         h#{$l} 
         q#{-.25*$l},#{-.5*$l} 0,#{-$l}'); /* squished box */

The above SCSS generates the CSS below:

d: path('M-50,-50 
         q-25,50 0,100 
         h100 
         q25,-50 0,100'); /* swollen box */

d: path('M-50,-50 
         q25,50 0,100 
         h100 
         q-25,-50 0,-100'); /* squished box */

See the Pen by thebabydino (@thebabydino) on CodePen.

In order to get the breathing effect, we animate between the swollen state and the squished state:

.box {
  d: path('M#{-.5*$l},#{-.5*$l} 
           q#{-.25*$l},#{.5*$l} 0,#{$l} 
           h#{$l} 
           q#{.25*$l},#{-.5*$l} 0,#{-$l}'); /* swollen box */
  animation: breathe .5s ease-in-out infinite alternate
}

@keyframes breathe {
  to {
    d: path('M#{-.5*$l},#{-.5*$l} 
             q#{.25*$l},#{.5*$l} 0,#{$l} 
             h#{$l} 
             q#{-.25*$l},#{-.5*$l} 0,#{-$l}'); /* squished box */
  }
}

Since the only thing that differs between the two states is the sign of the horizontal difference to the control points (the sign of the first number after the quadratic Bézier curve q command), we can simplify things with a mixin:

@mixin pdata($s: 1) {
  d: path('M#{-.5*$l},#{-.5*$l} 
           q#{-.25*$s*$l},#{.5*$l} 0,#{$l} 
           h#{$l} 
           q#{.25*$s*$l},#{-.5*$l} 0,#{-$l}')
}

.box {
  @include pdata();
  animation: breathe .5s ease-in-out infinite alternate
}

@keyframes breathe { to { @include pdata(-1) } }

This is pretty much what I’m doing for the actual breathing box demo, though the motion is slightly more discreet. Still, this does absolutely nothing for the generated CSS — we still have two long, ugly and almost identical paths in the compiled code.

However, if we were able to use a <div>, clipped with a clip-path: path() that supported all sorts of values, including calc() values inside, then we could make the sign a custom property --sgn, which we could then animate between -1 and 1 with the help of Houdini.

div.box {
  width: 40vmin; height: 20vmin;
  background: darkorange;
  --sgn: 1;
  clip-path: path(M 25%,0%
                  q calc(var(--sgn)*-25%),50% 0,100%
                  h 50%
                  q calc(var(--sgn)*25%),-50% 0,-100%);
  animation: breathe .5s ease-in-out infinite alternate
}

@keyframes breathe { to { --sgn: -1 } }

Being able to do this would make a whole world of difference. Our element would scale nicely with the viewport and so would the breathing box we clip out of it. And, most importantly, we wouldn’t need to repeat this clipping path in order to get the two different versions of it (the swollen one and the squished one), because using the sign custom property (--sgn) inside a calc() value would do the trick. As it is right now, however, clip-path: path() is pretty much useless.