如今,使用 clip-path
创建复杂形状是一件很容易的事,但为这些形状添加边框却始终是一个难题。CSS 没有提供可靠的解决方案,我们总是需要针对每个特定情况编写特定的“hacky”代码。在本文中,我将向您展示如何使用 CSS Paint API 解决此问题。
在我们深入探讨第三次实验之前,这里简要概述一下我们要构建的内容。并且,请注意,我们在这里所做的一切仅在基于 Chromium 的浏览器中受支持,因此您需要在 Chrome、Edge 或 Opera 中查看演示。 查看 caniuse 获取最新的支持情况。

您不会在那里找到复杂的 CSS 代码,而是一个通用的代码,我们只需要调整几个变量来控制形状。
主要思想
为了实现多边形边框,我将依靠 CSS clip-path
属性和使用 Paint API 创建的自定义蒙版相结合。

- 我们从一个基本的矩形形状开始。
- 我们应用
clip-path
来获取我们的多边形形状。 - 我们应用自定义蒙版来获取我们的多边形边框
CSS 设置
这是我们将要使用的 clip-path
步骤的 CSS 代码
.box {
--path: 50% 0,100% 100%,0 100%;
width: 200px;
height: 200px;
background: red;
display: inline-block;
clip-path: polygon(var(--path));
}
到目前为止,没有什么复杂的,但请注意 CSS 变量 --path
的使用。整个技巧都依赖于这个单个变量。由于我将使用 clip-path
和 mask
,两者都需要使用相同的参数,因此使用了 --path
变量。而且,是的,Paint API 将使用相同的变量来创建自定义蒙版。
整个过程的 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)
}
除了 clip-path
之外,我们还应用了自定义蒙版,并添加了一个额外的变量 --border
来控制边框的粗细。如您所见,到目前为止,所有内容仍然是非常基础和通用的 CSS 代码。毕竟,这是使 CSS Paint API 易于使用的优势之一。
JavaScript 设置
我强烈建议阅读我之前文章的 第一部分 以了解 Paint API 的结构。
现在,让我们看看在进入 JavaScript 时 paint()
函数内部发生了什么
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();
能够获取和设置 CSS 自定义属性是它们如此强大的原因之一。我们可以使用 JavaScript 首先读取 --path
变量的值,然后将其转换为点数组(如上面最上面一行所示)。因此,这意味着 50% 0,100% 100%,0 100%
成为蒙版的点,即 points = ["50% 0","100% 100%","0 100%"]
。
然后我们循环遍历这些点,使用 moveTo
和 lineTo
绘制一个多边形。这个多边形与使用 clip-path
属性在 CSS 中绘制的多边形完全相同。
最后,在绘制形状之后,我为其添加了一个描边。我使用 lineWidth
定义描边的粗细,并使用 strokeStyle
设置纯色。换句话说,只有形状的描边可见,因为我没有用任何颜色填充形状(即它是透明的)。
现在我们所要做的就是更新路径和粗细以创建任何多边形边框。值得注意的是,我们不限于纯色,因为我们正在使用 CSS background
属性。我们可以考虑渐变或图像。

如果我们需要添加内容,则必须考虑伪元素。否则,内容在过程中会被裁剪。支持内容并不难。我们将 mask
属性移动到伪元素。我们可以将 clip-path
声明保留在主要元素上。
到目前为止的问题?
我知道在查看了最后一个脚本后,您可能有一些迫切想要问的问题。请允许我抢先回答一些我猜您心里想到的问题。
cc()
函数是什么?
那个 我使用该函数将每个点的值转换为像素值。对于每个点,我获取 x
和 y
坐标(使用 points[i].trim().split(" ")
),然后转换这些坐标,以便在允许我们使用这些点进行绘制的画布元素内部使用它们。
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];
}
逻辑很简单:如果它是百分比值,我将使用宽度(或高度)来查找最终值。如果它是像素值,我只需获取不带单位的值。例如,如果我们有 [50% 20%]
,其中宽度等于 200px
,高度等于 100px
,那么我们将得到 [100 20]
。如果它是 [20px 50px]
,那么我们将得到 [20 50]
。以此类推。
clip-path
?
如果蒙版已将元素裁剪到形状的描边,为什么要使用 CSS 只使用蒙版是我最初的想法,但我遇到了这种方法的两个主要问题。第一个与 stroke()
的工作原理有关。来自 MDN
描边与路径的中心对齐;换句话说,一半的描边绘制在内部,一半绘制在外部。
那个“一半在内,一半在外”让我非常头疼,并且在将所有内容组合在一起时,我总是会遇到奇怪的溢出。这就是 CSS clip-path
发挥作用的地方;它裁剪了外部部分,只保留了内部部分——不再溢出!
您会注意到 ctx.lineWidth = 2*b
的使用。我添加了两倍的边框粗细,因为我将裁剪其中的一半,以最终获得围绕整个形状所需的正确粗细。
第二个问题与形状的可悬停区域有关。众所周知,蒙版不会影响该区域,我们仍然可以悬停/与整个矩形交互。同样,使用 clip-path
可以解决此问题,并且我们还可以将交互限制在形状本身。
以下演示说明了这两个问题。第一个元素同时具有蒙版和裁剪路径,而第二个元素只有蒙版。我们可以清楚地看到溢出问题。尝试悬停第二个元素以查看即使光标在三角形外部,我们也可以更改颜色。
@property
和边框值?
为什么要使用 这是一个有趣且相当棘手的部分。默认情况下,自定义属性(例如 --border
)被视为“CSSUnparsedValue”,这意味着它们被视为字符串。来自 CSS 规范
“CSSUnparsedValue”对象表示引用自定义属性的属性值。它们由字符串片段和变量引用的列表组成。
使用 @property
,我们可以注册自定义属性并为其指定类型,以便浏览器可以识别它并将其作为有效类型而不是字符串进行处理。在我们的例子中,我们将边框注册为 <length>
类型,以便稍后它成为 CSSUnitValue。这也允许我们对边框值使用任何长度单位(px
、em
、ch
、vh
等)。
这听起来可能有点复杂,但让我尝试用 DevTools 屏幕截图来说明一下区别。

console.log()
,其中我定义了 5em
。第一个已注册,但第二个未注册。在第一种情况下,浏览器识别类型并将值转换为像素值,这很有用,因为我们只需要在 paint()
函数内部使用像素值。在第二种情况下,我们将变量作为字符串获取,这不太有用,因为我们无法在 paint()
函数内部将 em
单位转换为 px
单位。
尝试所有单位。它始终会在 paint()
函数内部产生计算出的像素值。
--path
变量怎么样?
我想对 --path
变量使用相同的方法,但不幸的是,我认为我将 CSS 推到了它在这里所能做到的极限。使用 @property
,我们可以注册复杂类型,甚至多值变量。但这对于我们需要的路径来说仍然不够。
我们可以使用 +
和 #
符号来 定义以空格或逗号分隔的值列表,但我们的路径是以逗号分隔的空格分隔的百分比(或长度)值列表。我会使用类似 [<length-percentage>+]#
的东西,但它不存在。
对于路径,我不得不将其作为字符串值进行操作。这目前将我们限制在百分比和像素值上。出于这个原因,我定义了 cc()
函数将字符串值转换为像素值。
我们可以在 CSS 规范 中阅读
语法字符串的内部语法是 CSS 值定义语法 的一个子集。预计 规范 的未来级别将扩展允许的语法的复杂性,允许更接近 CSS 属性允许的全部范围的自定义属性。
即使语法扩展到能够注册路径,如果我们需要在路径中包含 calc()
,我们仍然会遇到问题
--path: 0 0,calc(100% - 40px) 0,100% 40px,100% 100%,0 100%;
在上面,calc(100% - 40px)
是浏览器认为是 <length-percentage>
的值,但浏览器无法计算该值,除非它知道百分比的参考。换句话说,我们无法在 paint()
函数内部获取等效的像素值,因为只有在 var()
中使用该值时才能知道该参考。
为了克服这个问题,我们可以扩展 cc()
函数来进行转换。我们完成了百分比值和像素值的转换,所以让我们将它们组合成一个转换。我们将考虑两种情况:calc(P% - Xpx)
和 calc(P% + Xpx)
。我们的脚本变为
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];
}
我们使用 indexOf()
来测试 calc
的存在,然后通过一些字符串操作,我们提取这两个值并找到最终的像素值。
并且,因此,我们还需要更新此行
p = points[i].trim().split(" ");
…到
p = points[i].trim().split(/(?!\(.*)\s(?![^(]*?\))/g);
由于我们需要考虑 calc()
,因此使用空格字符来分割将不起作用。这是因为 calc()
也包含空格。所以我们需要一个正则表达式。不要问我关于它的事情——这是在尝试了 Stack Overflow 上的很多方法后才起作用的方法。
这是一个基本的演示,说明我们迄今为止为支持 calc()
所做的更新。
请注意,我们将 calc()
表达式存储在变量 --v
中,我们将其注册为 <length-percentage>
。这也是技巧的一部分,因为如果我们这样做,浏览器会使用正确的格式。无论 calc()
表达式的复杂程度如何,浏览器始终将其转换为 calc(P% +/- Xpx)
格式。出于这个原因,我们只需要在 paint()
函数内部处理该格式。
下面是我们在每个示例中使用不同 calc()
表达式的不同示例
如果检查每个框的代码并查看 --v
的计算值,您将始终找到相同的格式,这非常有用,因为我们可以进行任何我们想要的计算。
需要注意的是,使用变量 --v
不是强制性的。我们可以直接在路径中包含 calc()
。我们只需要确保插入正确的格式,因为浏览器不会为我们处理它(请记住,我们无法注册路径变量,因此它是浏览器的字符串)。当我们需要在路径中包含多个 calc()
并且为每个 calc()
创建一个变量会使代码过长时,这可能很有用。我们将在最后看到一些示例。
我们可以有虚线边框吗?
我们可以!这只需要一条指令。<canvas>
元素已经有一个内置函数来绘制虚线笔划 setLineDash()
Canvas 2D API 的
CanvasRenderingContext2D
接口的setLineDash()
方法设置在描边线条时使用的线段图案。它使用一个值数组来指定线条和间隙的交替长度,这些长度描述了图案。
我们要做的就是引入另一个变量来定义我们的虚线图案。

在 CSS 中,我们只需添加一个 CSS 变量 --dash
,并在蒙版中添加以下内容
// ...
const d = properties.get('--dash').toString().split(',');
// ...
ctx.setLineDash(d);
我们还可以使用 lineDashOffset
控制偏移量。我们稍后将了解如何控制偏移量可以帮助我们实现一些很酷的动画。
@property
来注册虚线变量?
为什么不使用 从技术上讲,我们可以将虚线变量注册为 <length>#
,因为它是以逗号分隔的长度值列表。它确实有效,但是我无法在 paint()
函数内部检索这些值。我不知道这是不是一个错误、缺乏支持,或者我只是缺少拼图的一部分。
这是一个演示来说明这个问题
我正在使用以下方法注册 --dash
变量
@property --dash{
syntax: '<length>#';
inherits: true;
initial-value: 0;
}
…并在稍后将变量声明为
--dash: 10em,3em;
如果我们检查元素,我们可以看到浏览器正在正确处理变量,因为计算出的值是像素值

但我们只在 paint()
函数内部获取第一个值

在我找到此问题的修复方法之前,我只能将 --dash
变量用作字符串,就像 --path
一样。在这种情况下没什么大不了的,因为我认为我们不需要超过像素值。
用例!
在探索了此技术的幕后之后,现在让我们关注 CSS 部分并查看我们多边形边框的一些用例。
按钮集合
我们可以轻松生成具有很酷悬停效果的自定义形状按钮。
请注意 calc()
如何在最后一个按钮的路径中使用,就像我们之前描述的那样。它可以正常工作,因为我遵循了正确的格式。
面包屑
创建面包屑系统不再头疼!下面,您将找不到任何“hacky”或复杂的 CSS 代码,而是非常通用且易于理解的内容,我们只需调整一些变量即可。
卡片翻转动画
如果我们对厚度应用一些动画,就可以获得一些花哨的悬停效果。
我们可以使用同样的思路来创建一个揭示卡片的动画。
Callout & 语言泡
“我们到底怎么给那个小箭头添加边框??”我认为每个人在处理Callout或语言泡之类的设计时都遇到过这个问题。Paint API 使得这变得微不足道。
在该演示中,你会发现一些你可以扩展的例子。你只需要找到语言泡的路径,然后调整一些变量来控制边框厚度以及箭头的尺寸/位置。
动画虚线
在结束之前再讲最后一个。这次我们将重点关注虚线边框以创建更多动画。我们在按钮集合中已经做了一个,我们将虚线边框转换为实线边框。让我们再解决另外两个。
将鼠标悬停在下面,看看我们得到的效果。
那些使用过 SVG 一段时间的人可能熟悉我们通过动画 stroke-dasharray
实现的排序效果。Chris 甚至在一段时间前解决了这个概念。感谢 Paint API,我们可以在 CSS 中直接执行此操作。这个想法与我们在 SVG 中使用的几乎相同。我们定义了虚线变量。
--dash: var(--a),1000;
变量 --a
从 0
开始,因此我们的模式是一条实线(长度等于 0)和一个间隙(长度为 1000);因此没有边框。我们将 --a
动画化为一个较大的值以绘制我们的边框。
我们还讨论了使用 lineDashOffset
,我们可以将其用于另一种动画。将鼠标悬停在下面并查看结果。
最后,一个 CSS 解决方案来动画化虚线的位置,适用于任何类型的形状!
我所做的非常简单。我添加了一个额外的变量 --offset
,我为其应用从 0
到 N
的过渡。然后,在 paint()
函数内部,我执行以下操作。
const o = properties.get('--offset');
ctx.lineDashOffset=o;
就这么简单!不要忘记使用关键帧进行无限动画。
我们可以使动画持续运行,方法是从 0
偏移到 N
,其中 N
是虚线变量中使用的值的总和(在本例中为 10+15=25
)。我们使用负值来获得相反的方向。
我可能错过了很多用例,让您去发现!
据我所知,“类似数组” 或 “列表值” 属性值尚未完全规范化。
CSSStyleValue
还没有任何可以接受多个值的子类。我惊叹于你甚至找到了一个在涉及列表时获得
CSSUnitValue
的情况。在我迄今为止的实验中,我总是以通用或未解析的类和原始字符串结束。我以为我会得到一个“CSSStyleValue”数组,但事实是规范从未给出任何带有值数组(或列表)的示例。
至少我们知道这不是错误或其他东西。
大约 8 年来,一直有一个关于角落造型的 w3c 草案正在开发中。它以 border-radius 的想法为基础,添加了非圆角形状。请让 Chrome 知道您有兴趣在浏览器中使用此功能,方法是为以下错误加星标:https://bugs.chromium.org/p/chromium/issues/detail?id=1242936&q=corner%20shape&can=2
要在 paint 函数中获取列表变量,请使用 props.getAll(–dash) 而不是 props.get(–dash)。
在哪里可以找到此函数的文档?我在我读过的规范中从未见过它。
找到了。
https://mdn.org.cn/en-US/docs/Web/API/StylePropertyMapReadOnly/getAll
我不太明白为什么
inputProperties()
方法不允许你在 javascript 中定义属性的类型。它只是作为 CSSUnparsedValue 出现。类型需要使用
@property
在 CSS 中定义。如果你查看我的一些示例,你会发现我使用一些变量(例如 –border)来执行此操作。你好,
感谢如此棒的例子!我一直在使用它,我想我找到了浏览器中的一个错误。
当我将“button”更改为“a”时,它确实有效,但是当我添加“href”属性时,它就坏了。
这是一个已知的限制。paint() API 不适用于链接元素(可能是某种安全原因)。在这种情况下,将逻辑应用于伪元素。
否则,请查看本文以获取不需要 paint api 的解决方案:https://css-tricks.cn/cut-corners-using-css-mask-and-clip-path-properties/
如何将更多彩色边框与框中的内容结合使用?
每次我使用带有 box:before 的代码时,更多彩色的样式都会填充形状的整个背景。