A Better Way to Border Clipped Shapes
Creating complex shapes with clip-path is straightforward, but adding a border to those shapes remains a persistent annoyance. There’s no native CSS property that can outline a clipped area, so developers typically reach for overlays, shadows, or other fragile workarounds. The CSS Paint API offers a cleaner path: let the browser compute the border geometry for any polygon-shaped element, regardless of size or aspect ratio.
Registering a Custom Paint Worklet
The CSS Paint API lets you define a JavaScript class that draws directly onto an element’s background. To use it, you register a worklet and then reference it via the paint() function in CSS. The worklet receives the element’s bounding box dimensions as inputs, which means you can generate a border that perfectly matches a clip-path shape.
The core idea is to draw two identical polygons: one slightly larger (the border) and one smaller (the fill). By compositing them, you create a crisp outline. The worklet’s input properties, such as the border width and corner radius, can be read from CSS custom properties so you can adjust the border with standard styles.
Key Components of the Worklet
The worklet needs to access the polygon’s coordinates, but the CSS Paint API doesn’t automatically know about your clip-path value. You must pass those points as arguments or read them from custom properties. In practice, you’d define the polygon once and then use that same definition for both clipping and painting, guaranteeing the geometry stays in sync.
Inside the paint() method, you:
- Parse the polygon points from a string (e.g.,
50% 0, 100% 100%, 0 100%). - Scale those percentage coordinates to actual pixel values using
this.widthandthis.height. - Draw the polygon twice via
ctx.beginPath()andctx.lineTo(), applying a stroke for the border and a fill for the interior.
A crucial detail is handling the stroke correctly: a lineWidth of, say, 2px will draw half of that outside the path’s edge, so you should offset the entire path by half the stroke width to keep the border fully inside the element bounds. Otherwise, the edges might get clipped by the element’s box.
Handling Edge Cases and Non-Polygon Shapes
The approach works for any polygon, whether convex or concave. For curved shapes like ellipses or rounded rectangles, the logic changes because you can’t just lineTo points—you need to replicate the exact path of the clip-path. That requires extra code to model rounded corners or elliptical arcs, but the principle remains the same: draw the outline path, offset it, and overlay the fill.
Another subtlety is the element’s own background. If the element has a background color plus a clip-path, the painted background will show outside the clipped region unless you also set background: none or use another layer. The worklet, however, paints behind the element’s content, so you can safely use paint() only on a dedicated covering layer.
Putting It Together
A minimal implementation looks like this. First, register the worklet in your page’s JavaScript:
// worklet.js
registerPaint('polygon-border', class {
static get inputProperties() { return ['--border-width']; }
paint(ctx, geom) { /* ... your drawing logic ... */ }
});
Then, in CSS, request the paint worklet and apply the clip:
/* main.css */
.shape {
-webkit-mask: paint(polygon-border);
mask: paint(polygon-border);
clip-path: polygon(50% 0, 100% 100%, 0 100%);
}
You would call CSS.paintWorklet.addModule('worklet.js') in your app’s init code. The worklet then reads the polygon from a shared constant (not shown in CSS), but nothing prevents you from passing points as paint arguments instead.
If your polygon is simple, you can even skip the separate paint layer and directly execute the drawing in the same paint call. For production code, however, you should also handle device pixel ratio by multiplying your drawing coordinates by geom.width * devicePixelRatio, or else the border will look blurry on high-DPI screens.
Why This Beats the Hacks
The CSS-only alternatives for shape borders are notorious for needing explicit pixel values for every size change. A worklet scales automatically because the size and shape are driven by the same geometry definition. You change the shape in one place (clip-path) and the border updates itself—no extra scripts or magic numbers.
The worklet also gives you more styling control than an SVG background would. You can animate the border width via CSS transitions, apply different styles to separate edges (with more work), and keep everything in CSS-land without importing heavy JavaScript libraries.
One thing to keep in mind is browser support. The CSS Paint API is available in Chromium-based browsers, including Chrome and Edge, but Firefox and Safari still lack native support. For those, you’d need to provide a fallback, like a solid color background or no border at all. Feature detection via the CSS.paintWorklet object can decide which style to serve.
The Core Idea: Two Clips Are Better Than One
A polygon border is really two jobs: cutting the element into a polygon, and then keeping only the outline of that polygon. The CSS Paint API can handle both, but the cleanest approach combines a standard CSS clip-path with a custom mask drawn via paint().
The trick hinges on a single CSS variable, --path, that both the clip-path and the mask use to define the shape's vertices. The mask goes one step further: it draws only the stroke of the polygon, not its fill, so the element's background is visible only as a border.
The CSS Setup
Declaring the shape and the border thickness is straightforward. The clip-path does the actual clipping, while the custom mask does the border drawing. A --border variable controls the thickness.
.box {
--path: 50% 0,100% 100%,0 100%;
width: 200px;
height: 200px;
background: red;
display: inline-block;
clip-path: polygon(var(--path));
}
.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)
}
At this point, there's nothing exotic in the CSS. The real work happens in the paint() function, where the --path string is parsed into coordinates and drawn onto the canvas mask.
The JavaScript Engine
Inside paint(), the logic is to read the path, convert it to a set of points, trace those points on the canvas, and then apply a stroke to the outline.
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 key is converting the CSS string into pixel coordinates. The cc() helper function parses each point, handles both percentage and pixel units, and outputs values the canvas can use.
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];
}
After tracing the polygon, the code applies a stroke with ctx.lineWidth and ctx.strokeStyle. No fill is applied, so only the border remains visible. Because the background property backs the mask, this border can be a gradient, an image, or any other background effect.
Adding content to the bordered shape requires separating duties. The clip-path keeps the element clipped, but the mask must move to a pseudo-element so the content doesn't get clipped away by the mask.
A Closer Look at the Design Decisions
Why Use clip-path at All?
If the mask is already clipping to the shape's stroke, why keep the clip-path? The Canvas API's stroke() function draws half of the line on the inner side and half on the outer side of the path. Without clipping, that outer half creates an overflow that's hard to tame. The clip-path neatly removes the outer half, leaving a clean inner border. This double-thickness logic means setting ctx.lineWidth = 2*b where b is the desired final border width.
A second, subtler issue is hover behavior. Masking does not modify the element's hit-testing area. Without clipping, you could hover and interact with the full rectangle even where the mask makes it invisible. The clip-path fixes that, confining interaction to the visible shape.
Registering Types With @property
The --border variable can be registered as a real length value via @property. Untyped custom properties are sent to the Paint worklet as strings, which is useless for math. Registering it means the browser converts it to a pixel CSSUnitValue before it reaches paint().

console.log() on a variable where I defined 5em. The first one is registered but the second one is not.Registered length types work with any unit — px, em, vh, etc. — and the computed pixel value is always what the worklet receives.
The Limits of Typed Properties
The --path variable is a different story. The desired type — a comma-separated list of space-separated lengths — doesn't exist in the Property and Values API grammar today, which supports either + for space-separated or # for comma-separated lists, but not a nested combination. So --path must be parsed as a raw string.
Working with strings restricts units largely to percentages and pixels. Doing calc() inside the path poses its own challenge since the browser can't pre-compute those values before the reference context is known.
Handling calc() Expressions
Though we can't register a typed path, storing a calc expression in a registered variable helps tremendously. Variables registered as <length-percentage> get normalized by the browser into the exact format calc(P% + Xpx) or calc(P% - Xpx). The cc() function can be extended to recognize and parse this specific pattern, using string searches to extract the percentage and pixel parts.
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];
}
Even better, the calc() syntax can't be split on spaces because the expression itself contains spaces. A regex-based split handles both simple coordinates and complex calc() statements.
p = points[i].trim().split(" ");
p = points[i].trim().split(/(?!\(.*)\s(?![^(]*?\))/g);
Whether the calc expression is stored in its own registered variable or inlined in the path, the worklet always sees the same format, making the regression straightforward to support.
Dashed Borders and Animation
The canvas API offers a built-in method, setLineDash(), which makes creating dashed borders a single additional line of code.
// ...
const d = properties.get('--dash').toString().split(',');
// ...
ctx.setLineDash(d);
A simple CSS variable --dash defines the alternating line and gap lengths for that pattern.
The offset of the dashes is controllable too, using lineDashOffset(). Animating that offset creates marching-ants style movement that works on any arbitrary polygon shape without extra CSS or SVG work.
For an infinite loop, the animation offset goes from 0 to N, where N is the sum of the dash pattern. For a pattern of 10 15, sweeping from 0 to 25 animates the dashes seamlessly.
Drawing Polygon Borders with CSS Paint
The CSS Paint API gives us a way to draw programmatic visuals right in our stylesheets. While most examples focus on image effects and animations, another interesting application is drawing custom borders. A standard CSS border follows the rectangular box model, so creating a border that follows a polygon or any arbitrary shape normally requires SVG or clip paths. The Paint API opens up a different path: a worklet that renders the border as part of the element's background.
How the Worklet Works
The worklet receives the element's dimensions and a CSSStyleValue representing the border width, then computes the polygon's vertices. A key detail is that the computed border-width is a CSSPixelsValue, so the worklet calls .value on it to get the numeric pixel value. With that value, it can calculate the vertices of both the outer polygon (at the element's full dimensions) and the inner polygon (inset by the border width).
To make the resulting border visually rounded at the corners, the worklet doesn't simply draw straight lines between vertices. Instead, it adds corner arcs. The code determines the length of the side compared to the inset border width, and if the side is long enough, it uses a quadratic curve to create a smooth corner transition. When adjacent inset points come too close together for a curve to fit, it falls back to a simple line. This produces a polygon that looks natural, rather than one with sharp, overlapping corner joints.

Invoking the Custom Border
Applying the effect is straightforward: you register the paint worklet in JavaScript and then reference it in CSS via the background-image property. In the CSS, you set a custom --border-width, call paint(polygon-border), and use a regular background-color to fill the element. The paint worklet draws only the polygon's stroke, while the background-color fills the interior, creating the complete visual.
One important consideration is that the worklet only colors the polygon path itself. Any area outside your polygon shape but inside the element's box would be transparent unless you explicitly define what should be there. If you want a solid background, you may need to layer a second element or use a clip-path for the interior. Otherwise, you can leave it transparent for a clean outline-only look.
The polygon points in the worklet are relative, calculated as percentages of the element's width and height. This means the border automatically adapts to different element sizes without any extra configuration, making it easy to reuse across responsive layouts.

Styling Examples
In practice, you can use this to achieve effects that would be difficult with pure CSS. For instance, place an image inside a container that uses the polygon border. Padding the element creates separation between the image and the polygon edge. The result looks like a framed photo with an irregular, hand-drawn feel baked right into the layout.
You can also apply it to hover states. By changing the --border-width values in a :hover rule, the polygon border will smoothly transition its thickness. The browser handles the animation of the custom property, and the paint worklet re-runs on each frame to redraw the border at the new size.
The real benefit here is that all of the complexity lives in the JavaScript worklet. The CSS stays declarative. Once the worklet is registered, you can toggle polygon borders on any element with two lines of CSS, just as you would with a standard border property.



