Exploring the CSS Paint API: Polygon Border
Tags: css, html, webdev, tutorial
Published: 2021-10-20 13:48:13

Nowadays, 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.

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.

![CSS polygon border](https://i2.wp.com/css-tricks.com/wp-content/uploads/2021/09/overview-polygon-border-1.gif?resize=605%2C281&ssl=1)

### <center>[Live Demo](https://codepen.io/t_afif/pen/wvewXjZ)</center>

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.

![CSS polygon border](https://i1.wp.com/css-tricks.com/wp-content/uploads/2021/09/s_8C7B7FD228C95E70E08E0344785EF03E99A61895BF9BC4080F4DC424E6AFB8F0_1630408175106_overview2.png?w=674&ssl=1)

#### <center>[Live Demo](https://codepen.io/t_afif/pen/RwgrvEr)</center>

1. We start with a basic rectangular shape.
2. We apply `clip-path` to get our polygon shape.
3. 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:

```css
.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:

```css
.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

<sup>I highly recommend reading the first part of [my previous article](https://dev.to/this-is-learning/exploring-the-css-paint-api-image-fragmentation-effect-3ekl) to understand the structure of the Paint API.</sup>

Now, let’s see what is happening inside the `paint()` function as we jump into JavaScript:

```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](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/moveTo) and [lineTo](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/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](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/lineWidth) and I set a solid color using [strokeStyle](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/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.

![CSS polygon border](https://i2.wp.com/css-tricks.com/wp-content/uploads/2021/09/CSS-polygon-border-result.png?w=732&ssl=1)

#### <center>[Live Demo](https://codepen.io/t_afif/pen/zYzqzjO)</center>

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. 

#### <center>[Live Demo](https://codepen.io/t_afif/pen/dyRMQXP)</center>


----

## 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.

```javascript
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](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/stroke):

> 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](https://codepen.io/t_afif/pen/dyRMRby) 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](https://drafts.css-houdini.org/css-typed-om/#unparsedvalue-objects):

> ‘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](https://drafts.css-houdini.org/css-typed-om/#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.

![console log CSS value](https://i0.wp.com/css-tricks.com/wp-content/uploads/2021/09/s_8C7B7FD228C95E70E08E0344785EF03E99A61895BF9BC4080F4DC424E6AFB8F0_1630579831868_unit.png?w=702&ssl=1)

I am using `console.log()` on a variable where I defined `5em`. The first one is registered but the second one is not.

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](https://drafts.css-houdini.org/css-properties-values-api/#multipliers), 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](https://drafts.css-houdini.org/css-properties-values-api/#syntax-strings):

> 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:

```css
--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:

```javascript
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:

```javascript
p = points[i].trim().split(" ");
```

…to:

```javascript
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](https://codepen.io/t_afif/pen/zYzogdX) 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.

Here is a [Demo](https://codepen.io/t_afif/pen/mdwRVBr) with 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()](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/setLineDash):

> The setLineDash() method of the Canvas 2D API’s CanvasRenderingContext2D 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.

![CSS polygon dashed border](https://i2.wp.com/css-tricks.com/wp-content/uploads/2021/09/css-dashed-polygon-border.png?w=746&ssl=1)
#### <center>[Live Demo](https://codepen.io/t_afif/pen/RwgaqEV)</center>

In the CSS, we simply added a CSS variable, `--dash`, and within the mask is the following:

```javascript
// ...
const d = properties.get('--dash').toString().split(',');
// ...
ctx.setLineDash(d);
```

We can also control the offset using [lineDashOffset](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/lineDashOffset). We will see later how controlling the offset can help us reach some cool animations.

----


## 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.

{% codepen https://codepen.io/t_afif/pen/abwBPPz %}

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.

{% codepen https://codepen.io/t_afif/pen/powNXwR %}

### Card reveal animation

If we apply some animation to the thickness, we can get some [fancy hover effect](https://codepen.io/t_afif/pen/oNwBxmp). We can use that same idea to create an animation that reveals the card:

{% codepen https://codepen.io/t_afif/pen/ZEyLWgp %}


### 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.

{% codepen https://codepen.io/t_afif/pen/qBjRPeV %}

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:

{% codepen https://codepen.io/t_afif/pen/MWoJROX %}

Those who have worked with SVG for some time are likely familiar with the sort effect that we achieve by animating `stroke-dasharray`. 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:

```css
--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:

{% codepen https://codepen.io/t_afif/pen/JjJEVwP %}

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:

```javascript
const o = properties.get('--offset');
ctx.lineDashOffset=o;
```

As simple as that! Let’s not forget an infinite animation using keyframes:

{% codepen https://codepen.io/t_afif/pen/OJgWGKx %}

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!

----

### <center>You want to support me?</center>

[![buy me a coffee](https://cdn.buymeacoffee.com/buttons/default-orange.png)](https://www.buymeacoffee.com/afif)

<center>**OR**</center>

[![Become a patron](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/db3jtgy2i5l4w8slkps5.png)](https://www.patreon.com/temani)