- 原文地址:Thinking About The Cut-Out Effect: CSS or SVG?
- 原文作者:Ahmad Shadeed
- 译文出自:掘金翻译计划
》》》在最近的一个前端项目中,其中一个组件包括一个有趣的裁剪效果。在 CSS 或 SVG 中有多种方法可以实现这种效果,但每种方法都有其优点和缺点。我想探索下这个问题的解决方案并分享给大家。
要学习本文,你需要具备基本的 CSS 和 SVG 知识。如果没有也没问题,我会试着详细介绍每项内容。
让我们开始吧!Let's Go!!!
介绍
首先,让我介绍下什么是裁剪效果。指的是裁剪掉一个形状的部分区域。下面是一个例子:
请注意,我们通过从矩形中减去圆形来切出一个孔。在设计应用时,这很容易做到。然而,当要在网页上实现类似的效果时,由于各种原因,可能会变得有点挑战:
- 我们可能需要通过 JavaScript 来切换切口
- 里边可能包含图像或者文本
- 添加边框或阴影可能有挑战性
在接下来的几节中,我将探讨不同的示例以及我们如何使用 CSS 或 SVG 在其中实现裁剪效果。
用户头像
这是取自 Facebook Messenger 的真实案例。用户头像可以具有指示用户当前在线状态的绿色圆点。让我们来看一下:
我知道你在想什么。我们可以在绿色圆点上添加一个白色边框就完工了,对吗?嗯... 不是这样的。在深色模式下,它看起来像这样。
此外,它还会因为背景颜色更改而失效(例如:悬停效果。
同样,可以更改圆点的边框来匹配背景,但这不是最佳的解决方案。来看看我们还能怎么做。
解决方案 1 - Clip Path
这个方案结合了 SVG 和 CSS。首先,我们需要创建一个路径并将其导出为 SVG。你可以在你使用的设计软件中进行并将其导出为 SVG。对我来说,我使用 Figma 来做。
之后,我们需要复制path 的值并将它们转换为相对单位。默认情况下,SVG 路径点的值是绝对的。这意味着,如果宽度和高度发生变化,它们就会拉伸。为了提前解决这个问题,我们可以使用这个强大的工具。
然后,将该路径作为 <clipPath> 节点加入到页面里的内联 SVG 中.
<svg class="svg">
<clipPath id="circle" clipPathUnits="objectBoundingBox"><path d="M0.5,0 C0.776,0,1,0.224,1,0.5 C1,0.603,0.969,0.7,0.915,0.779 C0.897,0.767,0.876,0.76,0.853,0.76 C0.794,0.76,0.747,0.808,0.747,0.867 C0.747,0.888,0.753,0.908,0.764,0.925 C0.687,0.972,0.597,1,0.5,1 C0.224,1,0,0.776,0,0.5 C0,0.224,0.224,0,0.5,0"></path></clipPath>
</svg>
clipPathUnits 属性的值objectBoundingBox意味着路径内的值是相对于clip-path所应用的元素的边界框的。
.item {
clip-path:url("#circle");
}
太棒了。如果我们想为图像包含内边框,该怎么做呢?这将作为用户上传明亮图像时的后备选项。
很不幸,给 <img> 元素添加内阴影是不可能的。为了解决这个问题,我们可以使用额外的 HTML 元素(例如:span)或者伪元素。
我会使用伪元素来实现。
.item:after {
content: "";
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
border-radius: 50%;
border: 1px solid;
opacity: 0.2;
}
哎呀,怎么回事?边界出现在了不应该出现的地方。我们可以再次应用 clip-path,就能达到预期的效果了。
.item:after {
/* other styles */
clip-path:url("#my-clip-path");
}
最后我想验证的一件事是添加阴影的能力。可以使用 CSS filter drop-shadow,最棒的地方是它会按照头像的剪裁形状。
优点
- 跨浏览器,适用于所有主要版本的 Chrome、Edge、Firefox 和 Safari。
- 适用于非常基本的示例。可能会因边框或阴影而变得复杂。
缺点
- 要去除裁剪效果,我们需要修改路径。这在具有不同状态的组件中可能很难做到。
- 需要一些在设计应用中合并形状的经验。
解决方案 2 - CSS 蒙版
还可以通过组合 CSS 蒙版和渐变来制作裁剪效果。让我们看看如何实现。
通过使用 radial-gradient,我们可以绘制一个圆圈,然后用另一种颜色填充其余空间。如下图:
.item {
background-image: radial-gradient(circle 20px at calc(100% - 30px) calc(100% - 30px), yellow 30px, purple 0);
}
接下来,我们需要将圆形颜色改为透明并给元素添加border-radius。
.item {
background-image: radial-gradient(circle 20px at calc(100% - 30px) calc(100% - 30px), transparent 30px, purple 0);
border-radius: 50%;
}
基于此,我们可以将其用作 CSS 蒙版,如下所示。
.item {
-webkit-mask-image: radial-gradient(circle 20px at calc(100% - 30px) calc(100% - 30px), transparent 30px, purple 0);
border-radius: 50%;
}
这个方案里,添加外部边框是可以的,因为它在蒙版内。但是,对于内边框(又名:inset shadow),除非我们像前一个方案一样,使用另一个元素,否则是不可能的。
优点
- 跨浏览器,除 Firefox 之外的所有浏览器都需要加浏览器前缀。
缺点
- 在其他复杂示例中可能有限制
解决方案 3 - SVG 蒙版
首先,让我解释一下 SVG 蒙版是如何工作的。我们需要创建蒙版,然后将其应用到 SVG 自身的某个位置。如下所示。
它只是一个被圆圈遮罩起来的图像。在 SVG 中,它与 CSS 蒙版不同(语法上)。我们来分析一下上面的代码:
- 首先,我们有一个包含圆的
<mask>元素。 - 蒙版被应用在了
<image>元素上。在 SVG 里,它也可以是任意元素,如 group<g>。
让我们尝试在蒙版上添加另一个小圆圈。
太棒了。问题是,我们怎样才能做出裁剪效果呢?好吧,我在研究这个的过程中了解到了一件非常有趣的事情,来看看。
在蒙版中,填充为白色的对象代表我们想要显示的区域。而填充为黑色的对象代表我们想要隐藏的区域。很有趣,对吧?
让我们将小圆圈填充为黑色。
这就是诀窍。它非常有用,可以为我们开发人员提供很多可能。如果你是设计师,下面是更直观的解释。
当 mask 标签都是 白色时,会导致类似于合并两个形状(又叫做 union)的结果。如果其中一个是白色,另一个是黑色,则是一个形状将从另一个中减去。
下一步是为头像添加内边框。使用 SVG,这要容易得多。我们需要使用无填充颜色和半透明边框的 <circle> 元素。
<svg role="none">
<mask id="circle">
<circle fill="white" cx="100" cy="100" r="100"></circle>
<circle fill="black" cx="86%" cy="86%" r="18"></circle>
</mask>
<g mask="url(#circle)">
<image x="0" y="0" height="100%" width="100%" xlink:href="shadeed.jpg"
></image>
<circle fill="none" cx="100" cy="100" r="100" stroke="rgba(0,0,0,0.1)" stroke-width="2"></circle>
</g>
</svg>
注意,图像和边框需要在一个 group 内,并且该 group 有 mask 属性。
优点
- 实现方式简单
- 跨浏览器支持很好。
- 易于维护。
缺点
我想不出这个解决方案的任何缺点,只是对于不了解 SVG 的人来说会有点困难。
对我来说,这是最终胜出的方案。你知道 Facebook 正在使用这种方式吗?如果这告诉我们什么,那就是该方案适用于所有浏览器而不会出现问题,并提供了在不需要时禁用蒙版的途径。
已读头像
我们还有一个与前面的例子不同的裁剪效果。你看到的这个头像是在 Facebook Messenger 群聊中已读消息的指示器。
为了解决这个问题,我们需要有两个重叠的圆圈,然后从另一个圆圈中减去一个。
现在是研究解决方案的时候了。
解决方案 1 - 已读头像
尝试使用 clip-path 实现这个效果是非常有趣的体验。我将路径导出为 SVG 并将其值转换为相对值(类似于我在第一个示例中做的),下面是实现的效果。
当图像具有border-radius: 50% 时,导出的路径看上去会有点怪。很不幸,clip-path不适用于这个例子。
解决方案 2 - 已读头像
好的,让我们试试组合 CSS 渐变和蒙版。与前面的示例类似,我们需要绘制一个椭圆来表示裁剪效果。
.item {
-webkit-mask-image: radial-gradient(ellipse 54px 135px at 11px center, #0000 30px, #000 0);
}
完成了!不过,上面有一个小问题。如果仔细观察,你会发现椭圆的边缘是锯齿状的。
发生这种情况是因为第一种颜色的结束值是下一种颜色的开始值。换句话说,第一个颜色结束在30px处然后第二个颜色开始于30px 到 100% 结束。为了解决这个问题,我们可以将第二个颜色的开始位置改为30.5px。
.item {
-webkit-mask-image: radial-gradient(ellipse 54px 135px at 11px center, #0000 30px, #000 30.5px);
}
另一个方式是我们还可以通过 CSS 蒙版实现,也就是使用椭圆图像。
.item {
-webkit-mask-image: url(oval.svg);
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: -26px 54%;
-webkit-mask-size: 80px 140px;
}
如你所见,这不是我们想要的。我们想要的是反过来,也就是排除椭圆并显示其余的部分。我们该怎么做?在研究过程中,我了解到mask-composite允许我们添加多个蒙版并根据需要来进行合成。
我添加了一个和 linear-gradient 结束颜色相同的填充色蒙版,然后使用 mask-composite,只需要使用 exclude即可。
.item {
-webkit-mask-image: url(oval.svg), linear-gradient(#000, #000);
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: -26px 54%, 100% 100%;;
-webkit-mask-size: 80px 140px, 100% 100%;
mask-composite: exclude;
-webkit-mask-composite: destination-out;
}
注意:mask-composite 适用于 Firefox、Chrome 和 Safari 使用 -webkit-mask-composite。值 exclude 相当于 destination-out。
优点
- 跨浏览器,但是除了 Firefox 之外都需要浏览器前缀。
缺点
- 在其他示例中或许有限制或者变得复杂
解决方案 3 - 已读头像
还记得我们使用了两个 <circle> 元素当作蒙版,一个是白色,一个是黑色吗?这个方案里我们也要这么做。
<svg role="none" class="avatar-wrapper">
<mask id="cut">
<circle cx="50" cy="50" r="50" fill="white"></circle>
<circle fill="black" cx="-30" cy="50" r="50"></circle>
</mask>
<g mask="url(#cut)">
<image x="0" y="0" height="100%" width="100%" xlink:href="shadeed.jpg"></image>
<circle fill="none" stroke="rgba(0,0,0,0.1)" stroke-width="2"></circle>
</g>
</svg>
我所做的是对黑色 <circle> 的 cx 属性使用了负值。
在实际项目中,我们可能需要此组件的多个变体。大多数情况下,变化的会是大小方面。
考虑到这一点,最好是使用 CSS 变量处理<circle> 的 cx,cy和r的值。下面是用于处理头像和蒙版大小的 CSS:
.avatar {
--size: 100px; /* [1] */
width: var(--size);
height: var(--size);
}
/* [2] */
.avatar-circle {
cx: calc(var(--size) / 4 * -1);
cy: calc(var(--size) / 2);
r: calc(var(--size) / 2);
}
/* [3] */
.avatar-item {
margin-left: calc(var(--size) / 5.5 * -1);
}
我们来看一下上面的 CSS
- 定义头像的大小。这将用于宽度和高度属性。
- 使用
size变量来计算cx和cy的位置。 - 要定义两个头像之间的负边距值,我们需要将
size除以5.5再乘以-1。
我们来看看这个cx值是如何计算的:
cx: calc(var(--size) / 4 * -1);
如果还不知道cx和cy值是从圆心开始的(比如我)。这意味着,使用一半的值就可以将图像完全隐藏。参考下图:
为了便于理解,紫色代表白色圆圈(我们要显示的区域),轮廓的代表黑色圆圈(我们要隐藏的区域)。
For visualization purposes, the purple represents the white circle (the area we want to show), and the outlined one represents the black circle (the area we want to hide).
当黑色圆的 cx 值为 0 时,它已经隐藏了一半的图像。我们可以调整它并使用负值代替。该值可以根据裁剪区域的大小来确定。
对于头像之间的负边距,可以与cx计算值的方式相同,但要稍微大一些。这需要做些测试才能做到最好。
优点
- 出色的浏览器支持。它在所有主要浏览器中都表现一致。
- 通过使用 CSS 变量,整体可以由一个变量来控制。
缺点
- 需要一些 SVG 经验
网页标题
我们有一个带有居中 logo 的标题。我们在这里想要实现的是裁剪掉圆形 logo 后面的区域。
你想到的第一个办法是添加白色边框,对吗?它可以解决部分问题。但是在滚动时,logo 上的白色边框就会看起来有点奇怪。
那么,我们该如何解决呢?
解决方案 1 - CSS 径向渐变
与前面的示例类似,我们可以使用径向渐变在标题的中心制作一个裁剪区域。
.site-header {
background: radial-gradient(circle at 50% 70%, rgba(0, 0, 0, 0) 58px, #95a57f 58px, #95a57f 100%);
}
然后 logo 需要与裁剪区域的位置相同。为此我使用了 position: relative 和 top 属性。
.logo {
position: relative;
top: 10px;
}
这有用,但并不完美。我需要使 logo 和裁剪区域的大小动态化。这意味着,它们的大小应该根据视口大小缩小或扩大。我首先想到的是使用 CSS clamp() 函数。如果你想详细了解,我在这里介绍过。
:root {
--radius: clamp(48px, 4vw, 60px);
--logo-size: calc(calc(var(--radius) * 2) - 8px);
}
你猜对了,--radius 代表了圆的半径。然后,logo 大小应该是半径的两倍再加上透明区域的一点偏移。
一切正常,直到我注意到 top: 10px 不起作用,因为它需要与蒙版和 logo 的大小成比例。
我开始研究如何为logo的 top 属性使用动态值。首先,我列出了我所知道:
- Header 高度是
100px - 裁剪区域的中心位于 y 轴的 70%
- 我可以从
--radius变量中得到圆的半径
请看下面的介绍:
为了计算动态间距,我想出了以下公式。
Distance = (Header Height * 70%) - Radius
多亏了 calc() 函数,以下是将公式转换为 CSS 的方法。
:root {
--header-height: 100px;
--radius: clamp(48px, 4vw, 60px);
--logo-size: calc(calc(var(--radius) * 2) - 8px);
}
.logo {
display: block;
position: relative;
top: calc(var(--header-height) * 0.7 - var(--radius) + 2px);
width: var(--logo-size);
margin-left: auto;
margin-right: auto;
}
.site-header {
background: radial-gradient(
circle at 50% 70%,
rgba(0, 0, 0, 0) var(--radius),
#95a57f var(--radius),
#95a57f 100%
);
}
优点
- 优秀的浏览器支持
缺点
暂无
解决方案 2 - SVG 蒙版
这个方案,我使用了和上面相同的技术。有一个白色填充的矩形和一个黑色填充的圆形。这将创建一个裁剪效果。
<header class="site-header">
<img src="assets/logo.svg" alt="" />
<svg role="none" height="80">
<defs>
<mask id="hole">
<rect width="100%" height="100%" fill="white" />
<circle cx="50%" cy="80%" r="58" fill="black"></circle>
</mask>
</defs>
<rect width="100%" height="100%" mask="url(#hole)" />
</svg>
</header>
请记住,SVG 需要绝对定位来覆盖整个标题区域。
.site-header {
position: relative;
}
.site-header svg {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
}
总结
我非常享受写作和记录的过程。我喜欢 web 开发人员有很多方法来实现特定的结果。虽然有时会很棘手,但没关系。
希望这会对你有所帮助。如果是的话,请分享给你的朋友和同事。如果你有任何反馈,请在 Twitter 上@我或者给我留言!
感谢阅读!
资源
- MDN 上的 蒙版组合.