CSS Vector-Path Scaling

CSS allows for arbitrary shapes these days. Getting the details right turns out to be a little challenging.

Let’s say we have an SVG path definition that we want to use to shape a container element’s outline via CSS clip-path.

<path d="M1,1 l0,1 l40,1 a1,1 0 0,0,18 0 l40,-1 l0,-1 z" />

For simplicity, we might approximate this clipping shape with clip-path: polygon(…). However, unlike vector paths, polygons can’t reasonably be used for properly curved shapes; it’s all straight lines and edges.

lorem ipsum dolor sit amet

lorem ipsum dolor sit amet

lorem ipsum dolor sit amet

clip-path: polygon(45% 0, 50% 1rem, 55% 0, 100% 0, 100% 100%, 0 100%, 0 0);

Gratifyingly, CSS also supports SVG-style paths via clip-path: path(…). Yet if we insert the aforementioned path definition there, scaling is way off: The shape’s size appears to be absolute (100×100 pixels, as per its viewBox) rather than relative to our container element.

lorem ipsum dolor sit amet

lorem ipsum dolor sit amet

lorem ipsum dolor sit amet

clip-path: path("M1,1 l0,1 l40,1 a1,1 0 0,0,18 0 l40,-1 l0,-1 z");

As I understand it, that’s because the path’s user units are not tied to our container element’s size.

We could remedy that by moving our path definition into a separate SVG instead of embedding it directly within CSS, thus allowing for <clipPath clipPathUnits="objectBoundingBox"> and scaling the shape to match the container’s size. Unfortunately, not only is that cumbersome in various ways1, it also doesn’t quite result in the desired effect: While our shape now stretches horizontally as expected, vertical scaling seems exaggerated. In other words, our shape does not maintain its original aspect ratio with this technique. We can confirm this by changing the container element’s height.

lorem ipsum dolor sit amet

lorem ipsum dolor sit amet

lorem ipsum dolor sit amet

<svg width="0" height="0" viewBox="0 0 100 100">
    <defs>
        <clipPath id="clip-shape-arc" clipPathUnits="objectBoundingBox">
            <path d="M0.01,0.09 l0,0.09 l0.4,0.09 a0.01,0.09 0 0,0,0.18 0 l0.4,-0.09 l0,-0.09 z" />
        </clipPath>
    </defs>
</svg>
clip-path: url(#clip-shape-arc);

At this point, it seems prudent to further simplify our example: Let’s replace that fairly complex path definition with another polygonal approximation. As long as we’re still employing vector paths for that, the same principles apply and we can disregard headache-inducing curved shapes for the moment.

lorem ipsum dolor sit amet

lorem ipsum dolor sit amet

lorem ipsum dolor sit amet

<svg width="0" height="0" viewBox="0 0 100 100" class="nonvisual">
    <defs>
        <clipPath id="clip-shape-poly" clipPathUnits="objectBoundingBox">
            <path d="M0,0 L0.4,0.6 L0.6,0.6 L1,0 z" />
        </clipPath>
    </defs>
</svg>
clip-path: url(#clip-shape-poly);

That new path definition is comparatively straightforward: A sloped shape, up to 60 % tall and spanning the entire width. Unsurprisingly, the aspect-ratio issue can be observed here just the same: The slope’s angle changes along with the container element’s height. That’s exactly what we don’t want to happen though!

Temani Afif graciously took the time to suggest that masking might be more suitable than clipping here.2 And indeed, switching from clip-path to mask gives us a new set of affordances – notably the option to maintain aspect ratio, but also control both size and position more directly (though not necessarily intuitively; YMMV).

So let’s go back to our original path definition and plug that in – et voilà: This is pretty much the result we were hoping for in the first place!

lorem ipsum dolor sit amet

lorem ipsum dolor sit amet

lorem ipsum dolor sit amet

mask: no-repeat;
mask-image: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><path d="M1,1 l0,1 l40,1 a1,1 0 0,0,18 0 l40,-1 l0,-1 z" /></svg>');
mask-size: 100% auto;
mask-position: top left;

All that’s left now is inverting; remember we really wanted to clip outside rather than inside. CSS masking can do that by combining multiple layers, so we don’t even have to change our path definition!

lorem ipsum dolor sit amet

lorem ipsum dolor sit amet

lorem ipsum dolor sit amet

mask-image: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><path d="M1,1 l0,1 l40,1 a1,1 0 0,0,18 0 l40,-1 l0,-1 z" /></svg>'),
        linear-gradient(red, red);
mask-composite: exclude;

So yay, we’ve achieved the desired effect, eventually, with just a few lines of code. Nevertheless, it feels like I haven’t fully grokked how all the various pieces interact. This might be exacerbated by the fact that the respective affordances of clip-path and mask seem simultaneously very similar yet oddly incongruent – with any luck, I’ll develop a more solid mental model over time.