Exploring the CSS Paint API: Polygon Border
September 20, 2021Nowadays, creating complex shapes is an easy task using clip-path
, but adding a border to the shapes is always a pain. There is no robust CSS solution and we always need to produce specific “hacky” code for each particular case. In this article, I will show you how to solve this problem using the CSS Paint API.
Exploring the CSS Paint API series:
- Part 1: Image Fragmentation Effect
- Part 2: Blob Animation
- Part 3: Polygon Border (you are here!)
Before we dig into this third experimentation, Here is a small overview of what we are building. And, please note that everything we’re doing here is only supported in Chromium-based browsers so you’ll want to view the demos in Chrome, Edge, or Opera. See caniuse for the latest support.
You will find no complex CSS code there but rather a generic code where we only adjust a few variables to control the shape.
The main idea
In order to achieve the polygon border, I am going to rely on a combination of the CSS clip-path
property and a custom mask created with the Paint API.
- We start with a basic rectangular shape.
- We apply
clip-path
to get our polygon shape. - We apply the custom mask to get our polygon border
The CSS setup
Here’s the CSS for the clip-path
step we’ll get to:
.box {
--path: 50% 0,100% 100%,0 100%;
width: 200px;
height: 200px;
background: red;
display: inline-block;
clip-path: polygon(var(--path));
}
Nothing complex so far but note the use of the CSS variable --path
. The entire trick relies on that single variable. Since I will be using a clip-path
and a mask
, both need to use the same parameters, hence the --path
variable. And, yes, the Paint API will use that same variable to create the custom mask.
The CSS code for the whole process becomes:
.box {
--path: 50% 0,100% 100%,0 100%;
--border: 5px;
width: 200px;
height: 200px;
background: red;
display: inline-block;
clip-path: polygon(var(--path));
-webkit-mask: paint(polygon-border)
}
In addition to the clip-path
, we apply the custom mask, plus we add an extra variable, --border
, to control the thickness of the border. As you can see, everything is still pretty basic and generic CSS so far. After all, this is one of the things that makes the CSS Paint API so great to work with.
The JavaScript setup
I highly recommend reading the first part of my previous article to understand the structure of the Paint API.
Now, let’s see what is happening inside the paint()
function as we jump into JavaScript:
const points = properties.get('--path').toString().split(',');
const b = parseFloat(properties.get('--border').value);
const w = size.width;
const h = size.height;
const cc = function(x,y) {
// ...
}
var p = points[0].trim().split(" ");
p = cc(p[0],p[1]);
ctx.beginPath();
ctx.moveTo(p[0],p[1]);
for (var i = 1; i < points.length; i++) {
p = points[i].trim().split(" ");
p = cc(p[0],p[1]);
ctx.lineTo(p[0],p[1]);
}
ctx.closePath();
ctx.lineWidth = 2*b;
ctx.strokeStyle = '#000';
ctx.stroke();
The ability to get and set CSS custom properties is one of the reasons they’re so great. We can reach for JavaScript to first read the value of the --path
variable, then convert it into an array of points (seen on the very first line above). So, that means 50% 0,100% 100%,0 100%
become the points for the mask, i.e. points = ["50% 0","100% 100%","0 100%"]
.
Then we loop through the points to draw a polygon using moveTo
and lineTo
. This polygon is exactly the same as the one drawn in CSS with the clip-path
property.
Finally, and after drawing the shape, I add a stroke to it. I define the thickness of the stroke using lineWidth
and I set a solid color using strokeStyle
. In other words, only the stroke of the shape is visible since I am not filling the shape with any color (i.e. it’s transparent).
Now all we have to do is to update the path and the thickness to create any polygon border. It’s worth noting that we are not limited to solid color here since we are using the CSS background
property. We can consider gradients or images.
In case we need to add content, we have to consider a pseudo-element. Otherwise, the content gets clipped in the process. It’s not incredibly tough to support content. We move the mask
property to the pseudo-element. We can keep the clip-path
declaration on the main element.
Questions so far?
I know you probably have some burning questions you want to ask after looking over that last script. Allow me to preemptively answer a couple things I bet you have in mind.
What is that cc()
function?
I am using that function to convert the value of each point into pixel values. For each point, I get both x
and y
coordinates — using points[i].trim().split(" ")
— and then I convert those coordinates to make them usable inside the canvas element that allows us to draw with those points.
const cc = function(x,y) {
var fx=0,fy=0;
if (x.indexOf('%') > -1) {
fx = (parseFloat(x)/100)*w;
} else if(x.indexOf('px') > -1) {
fx = parseFloat(x);
}
if (y.indexOf('%') > -1) {
fy = (parseFloat(y)/100)*h;
} else if(y.indexOf('px') > -1) {
fy = parseFloat(y);
}
return [fx,fy];
}
The logic is simple: if it’s a percentage value, I use the width (or the height) to find the final value. If it’s a pixel value, I simply get the value without the unit. If, for, example we have [50% 20%]
where the width is equal to 200px
and the height is equal to 100px
, then we get [100 20]
. If it’s [20px 50px]
, then we get [20 50]
. And so on.
Why are you using CSS clip-path
if the mask is already clipping the element to the stroke of the shape?
Using only the mask was the first idea I had in mind, but I stumbled upon two major issues with that approach. The first is related to how stroke()
works. From MDN:
Strokes are aligned to the center of a path; in other words, half of the stroke is drawn on the inner side, and half on the outer side.
That “half inner side, half outer side” gave me a lot of headaches, and I always end up with a strange overflow when putting everything together. That’s where CSS clip-path
helps; it clips the outer part and only keeps the inner side — no more overflow!
You will notice the use of ctx.lineWidth = 2*b
. I am adding double the border thickness because I will clip half of it to end with the right thickness needed around the entire shape.
The second issue is related to the shape’s hover-able area. It’s known that masking does not affect that area and we can still hover/interact with the whole rectangle. Again, reaching for clip-path
fixes the issue, plus we limit the interaction just to the shape itself.
The following demo illustrates these two issues. The first element has both a mask and clip-path, while the second only has the mask. We can clearly see the overflow issue. Try to hover the second one to see that we can change the color even if the cursor is outside the triangle.
Why are you using @property
with the border value?
This is an interesting — and pretty tricky — part. By default, custom properties (like --border
) are considered a “CSSUnparsedValue” which means they are treated as strings. From the CSS spec:
‘CSSUnparsedValue’ objects represent property values that reference custom properties. They are comprised of a list of string fragments and variable references.
With @property
, we can register the custom property and give it a type so that it can be recognized by the browser and handled as a valid type instead of a string. In our case, we are registering the border as a <length>
type so later it becomes a CSSUnitValue. What this also does is allow us to use any length unit (px
, em
, ch
,vh
, etc.) for the border value.
This may sound a bit complex but let me try to illustrate the difference with a DevTools screenshot.
In the first case, the browser recognizes the type and makes the conversion into a pixel value, which is useful since we only need pixel values inside the paint()
function. In the second case, we get the variable as a string which is not very useful since we cannot convert em
units into px
units inside the paint()
function.
Try all the units. It will always results with the computed pixel value inside the paint()
function.
What about the --path
variable?
I wanted to use the same approach with the --path
variable but, unfortunately, I think I pushed CSS right up to the limits of what it can do here. Using @property
, we can register complex types, even multi-value variables. But that’s still not enough for the path we need.
We can use the +
and #
symbols to define a space-separated or comma-separated list of values, but our path is a comma-separated list of space-separated percentage (or length) values. I would use something like [<length-percentage>+]#
, but it doesn’t exist.
For the path, I am obliged to manipulate it as a string value. That limits us just to percentage and pixel values for now. For this reason, I defined the cc()
function to convert the string values into pixel values.
We can read in the CSS spec:
The internal grammar of the syntax strings is a subset of the CSS Value Definition Syntax. Future levels of the specification are expected to expand the complexity of the allowed grammar, allowing custom properties that more closely resemble the full breadth of what CSS properties allow.
Even if the grammar is extend to be able to register the path, we will still face issue in case we need to include calc()
inside our path:
--path: 0 0,calc(100% - 40px) 0,100% 40px,100% 100%,0 100%;
In the above, calc(100% - 40px)
is a value that the browser considers a <length-percentage>
, but the browser cannot compute that value until it knows the reference for the percentage. In other words, we cannot get the equivalent pixel value inside the paint()
function since the reference can only be known when the value gets used within var()
.
To overcome this, we can can extend the cc()
function to do the conversion. We did the conversion of a percentage value and a pixel value, so let’s combine those into one conversion. We will consider 2 cases: calc(P% - Xpx)
and calc(P% + Xpx)
. Our script becomes:
const cc = function(x,y) {
var fx=0,fy=0;
if (x.indexOf('calc') > -1) {
var tmp = x.replace('calc(','').replace(')','');
if (tmp.indexOf('+') > -1) {
tmp = tmp.split('+');
fx = (parseFloat(tmp[0])/100)*w + parseFloat(tmp[1]);
} else {
tmp = tmp.split('-');
fx = (parseFloat(tmp[0])/100)*w - parseFloat(tmp[1]);
}
} else if (x.indexOf('%') > -1) {
fx = (parseFloat(x)/100)*w;
} else if(x.indexOf('px') > -1) {
fx = parseFloat(x);
}
if (y.indexOf('calc') > -1) {
var tmp = y.replace('calc(','').replace(')','');
if (tmp.indexOf('+') > -1) {
tmp = tmp.split('+');
fy = (parseFloat(tmp[0])/100)*h + parseFloat(tmp[1]);
} else {
tmp = tmp.split('-');
fy = (parseFloat(tmp[0])/100)*h - parseFloat(tmp[1]);
}
} else if (y.indexOf('%') > -1) {
fy = (parseFloat(y)/100)*h;
} else if(y.indexOf('px') > -1) {
fy = parseFloat(y);
}
return [fx,fy];
}
We’re using indexOf()
to test the existence of calc
, then, with some string manipulation, we extract both values and find the final pixel value.
And, as a result, we also need to update this line:
p = points[i].trim().split(" ");
…to:
p = points[i].trim().split(/(?!\(.*)\s(?![^(]*?\))/g);
Since we need to consider calc()
, using the space character won’t work for splitting. That’s because calc()
also contains spaces. So we need a regex. Don’t ask me about it — it’s the one that worked after trying a lot from Stack Overflow.
Here is basic demo to illustrate the update we did so far to support calc()
Notice that we have stored the calc()
expression within the variable --v
that we registered as a <length-percentage>
. This is also a part of the trick because if we do this, the browser uses the correct format. Whatever the complexity of the calc()
expression, the browser always converts it to the format calc(P% +/- Xpx)
. For this reason, we only have to deal with that format inside the paint()
function.
Below different examples where we are using a different calc()
expression for each one:
If you inspect the code of each box and see the computed value of --v
, you will always find the same format which is super useful because we can have any kind of calculation we want.
It should be noted that using the variable --v
is not mandatory. We can include the calc()
directly inside the path. We simply need to make sure we insert the correct format since the browser will not handle it for us (remember that we cannot register the path variable so it’s a string for the browser). This can be useful when we need to have many calc()
inside the path and creating a variable for each one will make the code too lengthy. We will see a few examples at the end.
Can we have dashed border?
We can! And it only takes one instruction. The <canvas>
element already has a built-in function to draw dashed stroke setLineDash()
:
The
setLineDash()
method of the Canvas 2D API’sCanvasRenderingContext2D
interface sets the line dash pattern used when stroking lines. It uses an array of values that specify alternating lengths of lines and gaps which describe the pattern.
All we have to do is to introduce another variable to define our dash pattern.
In the CSS, we simply added a CSS variable, --dash
, and within the mask is the following:
// ...
const d = properties.get('--dash').toString().split(',');
// ...
ctx.setLineDash(d);
We can also control the offset using lineDashOffset
. We will see later how controlling the offset can help us reach some cool animations.
Why not use @property
instead to register the dash variable?
Technically, we can register the dash variable as a <length>#
since it’s a comma-separated list of length values. It does work, but I wasn’t able to retrieve the values inside the paint()
function. I don’t know if it’s a bug, a lack of support, or I’m just missing a piece of the puzzle.
Here is a demo to illustrate the issue:
I am registering the --dash
variable using this:
@property --dash{
syntax: '<length>#';
inherits: true;
initial-value: 0;
}
…and later declaring the variable as this:
--dash: 10em,3em;
If we inspect the element, we can see that the browser is handling the variable correctly since the computed values are pixel ones
But we only get the first value inside the paint()
function
Until I find the a fix for this, I am stuck using the --dash
variable as a string, like the --path
. Not a big deal in this case as I don’t think we will need more than pixel values.
Use cases!
After exploring the behind the scene of this technique, let’s now focus on the CSS part and check out a few uses cases for our polygon border.
A collection of buttons
We can easily generate custom shape buttons having cool hover effect.
Notice how calc()
is used inside the path of the last button the way we described it earlier. It works fine since I am following the correct format.
Breadcrumbs
No more headaches when creating a breadcrumb system! Below, you will find no “hacky” or complex CSS code, but rather something that’s pretty generic and easy to understand where all we have to do is to adjust a few variables.
Card reveal animation
If we apply some animation to the thickness, we can get some fancy hover effect
We can use that same idea to create an animation that reveals the card:
Callout & speech bubble
“How the hell we can add border to that small arrow???” I think everyone has stumbled on this issue when dealing with either a callout or speech bubble sort of design. The Paint API makes this trivial.
In that demo, you will find a few examples that you can extend. You only need to find the path for your speech bubble, then adjust some variables to control the border thickness and the size/position of the arrow.
Animating dashes
A last one before we end. This time we will focus on the dashed border to create more animations. We already did one in the button collection where we transform a dashed border into a solid one. Let’s tackle two others.
Hover the below and see the nice effect we get:
Those who have worked with SVG for some time are likely familiar with the sort effect that we achieve by animating stroke-dasharray
. Chris even tackled the concept a while back. Thanks to the Paint API, we can do this directly in CSS. The idea is almost the same one we use with SVG. We define the dash variable:
--dash: var(--a),1000;
The variable --a
starts at 0
, so our pattern is a solid line (where the length equals 0) with a gap (where length 1000); hence no border. We animate --a
to a big value to draw our border.
We also talked about using lineDashOffset
, which we can use for another kind of animation. Hover the below and see the result:
Finally, a CSS solution to animate the position of dashes that works with any kind of shape!
What I did is pretty simple. I added an extra variable, --offset
, to which I apply a transition from 0
to N
. Then, inside the paint()
function, I do the following:
const o = properties.get('--offset');
ctx.lineDashOffset=o;
As simple as that! Let’s not forget an infinite animation using keyframes:
We can make the animation run continuously by offsetting 0
to N
where N
is the sum of the values used in the dash variable (which, in our case, is 10+15=25
). We use a negative value to have the opposite direction direction.
I have probably missed a lot of use cases that I let you discover!
Exploring the CSS Paint API series:
- Part 1: Image Fragmentation Effect
- Part 2: Blob Animation
- Part 3: Polygon Border (you are here!)