我经常听到一个问题:是否可以用渐变而不是纯色来创建阴影? 没有特定的 CSS 属性可以做到这一点(相信我,我已经找过了),你找到的任何关于它的博客文章基本上都是用 CSS 技巧来近似实现渐变。 我们将在接下来的内容中介绍其中一些技巧。
但首先… 另一篇关于渐变阴影的文章? 真的吗?
是的,这又是关于这个主题的另一篇文章,但它有所不同。 我们将一起突破极限,找到一个解决方案,解决我在其他地方没有看到的问题:透明度。 大多数技巧都适用于具有不透明背景的元素,但如果我们具有透明背景怎么办? 我们将在本文中探讨这种情况!
在我们开始之前,让我介绍一下 我的渐变阴影生成器。 你所要做的就是调整配置,然后获取代码。 但请继续阅读,因为我将帮助你了解生成代码背后的所有逻辑。
非透明解决方案
让我们从适用于 80% 情况的解决方案开始。 最典型的情况:你正在使用一个具有背景的元素,你需要为它添加一个渐变阴影。 没有透明度问题需要考虑。
解决方案是依赖于定义了渐变的伪元素。 你将它放在实际元素的后面,并 对其应用模糊过滤器。
.box {
position: relative;
}
.box::before {
content: "";
position: absolute;
inset: -5px; /* control the spread */
transform: translate(10px, 8px); /* control the offsets */
z-index: -1; /* place the element behind */
background: /* your gradient here */;
filter: blur(10px); /* control the blur */
}
它看起来很多代码,而且确实很多。 以下是如何使用 box-shadow
来实现它,如果我们使用的是纯色而不是渐变。
box-shadow: 10px 8px 10px 5px orange;
这应该让你对第一个代码段中的值的作用有一个很好的了解。 我们有 X 和 Y 偏移量、模糊半径和扩散距离。 请注意,我们需要一个来自 inset
属性的负扩散距离。
以下是一个演示,展示了渐变阴影与经典 box-shadow
相比的效果。
如果你仔细观察,你会注意到两个阴影略有不同,尤其是模糊部分。 这并不奇怪,因为我相信 filter
属性的算法与 box-shadow
的算法不同。 这并不算什么大问题,因为最终结果非常相似。
这个解决方案很好,但仍然有一些与 z-index: -1
声明相关的缺点。 是的,那里发生了 “堆叠上下文”!
我将 transform
应用于主元素,然后就结束了! 阴影不再在元素下方。 这不是错误,而是堆叠上下文的逻辑结果。 别担心,我不会开始一个关于堆叠上下文的无聊解释(我在 Stack Overflow 线程中已经做过解释了),但我仍然会向你展示如何解决它。
我推荐的第一个解决方案是使用 3D transform
.box {
position: relative;
transform-style: preserve-3d;
}
.box::before {
content: "";
position: absolute;
inset: -5px;
transform: translate3d(10px, 8px, -1px); /* (X, Y, Z) */
background: /* .. */;
filter: blur(10px);
}
我们不会使用 z-index: -1
,而是沿着 Z 轴使用负平移。 我们将把所有内容放在 translate3d()
中。 不要忘记在主元素上使用 transform-style: preserve-3d
; 否则,3D transform
将不会生效。
据我所知,这个解决方案没有任何副作用……但也许你会发现一个。 如果是这样,请在评论部分分享,让我们试着找到解决办法!
如果由于某种原因你无法使用 3D transform
,那么另一个解决方案是依赖于两个伪元素——::before
和 ::after
。 一个创建渐变阴影,另一个复制主背景(以及你可能需要的其他样式)。 这样,我们可以轻松地控制两个伪元素的堆叠顺序。
.box {
position: relative;
z-index: 0; /* We force a stacking context */
}
/* Creates the shadow */
.box::before {
content: "";
position: absolute;
z-index: -2;
inset: -5px;
transform: translate(10px, 8px);
background: /* .. */;
filter: blur(10px);
}
/* Reproduces the main element styles */
.box::after {
content: """;
position: absolute;
z-index: -1;
inset: 0;
/* Inherit all the decorations defined on the main element */
background: inherit;
border: inherit;
box-shadow: inherit;
}
需要注意的是,我们通过在主元素上声明 z-index: 0
或 任何其他具有相同效果的属性 来强制主元素创建一个堆叠上下文。 此外,不要忘记伪元素将主元素的填充框作为参考。 所以,如果主元素有边框,你需要在定义伪元素样式时考虑这一点。 你会注意到,我在 ::after
上使用了 inset: -2px
来考虑主元素上定义的边框。
正如我所说,这个解决方案在大多数情况下足够好,只要你不需要支持透明度。 但是我们来挑战一下,突破极限,即使你不需要接下来的内容,也请继续关注。 你可能会学到一些可以在其他地方使用的 CSS 技巧。
透明解决方案
让我们从 3D transform
中继续,并从主元素中删除背景。 我将从一个偏移量和扩散距离都等于 0
的阴影开始。
我们的想法是找到一种方法,在保持外部部分的同时,裁剪或隐藏元素区域(绿色边框内)的所有内容。 我们将使用 clip-path
来实现。 但是你可能想知道 clip-path
如何在元素内部进行裁剪。
确实,没有办法做到这一点,但是我们可以使用特定的多边形模式来模拟它。
clip-path: polygon(-100vmax -100vmax,100vmax -100vmax,100vmax 100vmax,-100vmax 100vmax,-100vmax -100vmax,0 0,0 100%,100% 100%,100% 0,0 0)
瞧! 我们有一个支持透明度的渐变阴影。 我们所做的只是在之前的代码中添加一个 clip-path
。 下面是一个图来说明多边形部分。

蓝色区域是应用 clip-path
后可见的部分。 我只使用蓝色来说明这个概念,但在现实中,我们只会看到该区域内的阴影。 如你所见,我们定义了四个点,它们具有一个很大的值(B
)。 我的大值是 100vmax
,但它可以是你想要的任何大值。 我们的想法是确保我们有足够的空间用于阴影。 我们还有四个点,它们是伪元素的角。
箭头说明了定义多边形的路径。 我们从 (-B, -B)
开始,直到到达 (0,0)
。 总共我们需要 10 个点。 不是 8 个点,因为两个点在路径中重复了两次((-B,-B)
和 (0,0)
)。
我们还有一件事要做,那就是考虑扩散距离和偏移量。 上面的演示之所以有效,是因为它是一个特殊情况,偏移量和扩散距离都等于 0
。
让我们定义扩散距离,看看会发生什么。 请记住,我们使用带负值的 inset
来实现这一点。
现在,伪元素比主元素大,因此 clip-path
裁剪的范围比我们需要的更大。 请记住,我们始终需要裁剪主元素内部的部分(示例中绿色边框内的区域)。 我们需要调整 clip-path
内的四个点的位置。
.box {
--s: 10px; /* the spread */
position: relative;
}
.box::before {
inset: calc(-1 * var(--s));
clip-path: polygon(
-100vmax -100vmax,
100vmax -100vmax,
100vmax 100vmax,
-100vmax 100vmax,
-100vmax -100vmax,
calc(0px + var(--s)) calc(0px + var(--s)),
calc(0px + var(--s)) calc(100% - var(--s)),
calc(100% - var(--s)) calc(100% - var(--s)),
calc(100% - var(--s)) calc(0px + var(--s)),
calc(0px + var(--s)) calc(0px + var(--s))
);
}
我们定义了一个 CSS 变量 --s
来表示扩散距离,并更新了多边形点。 我没有触及我使用大值的地方。 我只更新了定义伪元素角的点。 我将所有零值增加 --s
,并将 100%
值减少 --s
。
偏移量也是相同的逻辑。 当我们平移伪元素时,阴影会错位,我们需要再次校正多边形并将点移动到相反的方向。
.box {
--s: 10px; /* the spread */
--x: 10px; /* X offset */
--y: 8px; /* Y offset */
position: relative;
}
.box::before {
inset: calc(-1 * var(--s));
transform: translate3d(var(--x), var(--y), -1px);
clip-path: polygon(
-100vmax -100vmax,
100vmax -100vmax,
100vmax 100vmax,
-100vmax 100vmax,
-100vmax -100vmax,
calc(0px + var(--s) - var(--x)) calc(0px + var(--s) - var(--y)),
calc(0px + var(--s) - var(--x)) calc(100% - var(--s) - var(--y)),
calc(100% - var(--s) - var(--x)) calc(100% - var(--s) - var(--y)),
calc(100% - var(--s) - var(--x)) calc(0px + var(--s) - var(--y)),
calc(0px + var(--s) - var(--x)) calc(0px + var(--s) - var(--y))
);
}
还有两个用于偏移量的变量:--x
和 --y
。 我们在 transform
中使用它们,并且还更新了 clip-path
值。 我们仍然没有触及具有大值的点,但我们偏移了所有其他点——我们将 --x
从 X 坐标中减去,并将 --y
从 Y 坐标中减去。
现在,我们要做的就是更新几个变量来控制渐变阴影。 趁此机会,让我们也将模糊半径也定义为一个变量。
我们还需要 3D
transform
技巧吗?
这完全取决于边框。 不要忘记,伪元素的参考是填充框,所以如果你对主元素应用边框,就会有重叠。 你要么保留 3D transform
技巧,要么更新 inset
值来考虑边框。
以下是之前的演示,其中更新了 inset
值来代替 3D transform
我认为这是一个更合适的方法,因为扩散距离将更加准确,因为它从边框框而不是填充框开始。 但是你需要根据主元素的边框来调整 inset
值。 有时候,元素的边框是未知的,你必须使用之前的解决方案。
在早期的非透明解决方案中,你可能会遇到堆叠上下文问题。 在透明解决方案中,你可能会遇到边框问题。 现在你有了一些选择,可以解决这些问题。 3D 平移技巧是我最喜欢的解决方案,因为它可以解决所有问题(在线生成器 也会考虑这一点)。
添加边框半径
如果你尝试在使用我们开始使用的非透明解决方案时将border-radius
添加到元素中,这将是一个相当简单的任务。你只需要从主元素继承相同的数值,然后就完成了。
即使你没有边框半径,定义border-radius: inherit
也是一个好主意。这可以处理你以后可能想要添加的任何潜在的border-radius
,或者来自其他地方的边框半径。
在处理透明解决方案时,情况就不同了。不幸的是,这意味着要找到另一种解决方案,因为clip-path
无法处理曲线。这意味着我们将无法切割主元素内部的区域。
我们将介绍mask
属性到混合中。
这一部分非常繁琐,我努力寻找一个不依赖于魔法数字
的通用解决方案。我最终找到了一个非常复杂的解决方案,它只使用一个伪元素,但代码是一堆意大利面条,只涵盖了几个特定情况。我认为探索这条路线不值得。
为了简化代码,我决定插入一个额外的元素。这是标记
<div class="box">
<sh></sh>
</div>
我使用自定义元素<sh>
来避免与外部 CSS 的任何潜在冲突。我本可以使用<div>
,但因为它是一个常见的元素,很容易被来自其他地方的另一个 CSS 规则所定位,从而破坏我们的代码。
第一步是定位<sh>
元素并故意创建一个溢出
.box {
--r: 50px;
position: relative;
border-radius: var(--r);
}
.box sh {
position: absolute;
inset: -150px;
border: 150px solid #0000;
border-radius: calc(150px + var(--r));
}
代码可能看起来有点奇怪,但随着我们继续,我们会了解它背后的逻辑。接下来,我们使用<sh>
的伪元素创建渐变阴影。
.box {
--r: 50px;
position: relative;
border-radius: var(--r);
transform-style: preserve-3d;
}
.box sh {
position: absolute;
inset: -150px;
border: 150px solid #0000;
border-radius: calc(150px + var(--r));
transform: translateZ(-1px)
}
.box sh::before {
content: "";
position: absolute;
inset: -5px;
border-radius: var(--r);
background: /* Your gradient */;
filter: blur(10px);
transform: translate(10px,8px);
}
如你所见,伪元素使用与之前所有示例相同的代码。唯一的区别是在<sh>
元素而不是伪元素上定义的 3D transform
。目前,我们有一个没有透明功能的渐变阴影。
请注意,<sh>
元素的区域是用黑色轮廓定义的。为什么我要这样做?因为这样,我就可以在其上应用一个mask
来隐藏绿色区域内的部分,并保留需要看到阴影的溢出部分。
我知道这有点棘手,但与clip-path
不同,mask
属性不考虑元素外部的区域来显示和隐藏事物。这就是为什么我被迫引入额外的元素 - 来模拟“外部”区域。
此外,请注意,我使用border
和inset
的组合来定义该区域。这使我能够保持额外元素的填充框与主元素相同,因此伪元素不需要额外的计算。
使用额外元素的另一个有用之处在于元素是固定的,只有伪元素在移动(使用translate
)。这将使我能够轻松地定义蒙版,这是这个技巧的最后一步。
mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
mask-composite: exclude;
完成了!我们有了渐变阴影,它支持border-radius
!你可能期待一个复杂的mask
值,其中包含大量的渐变,但没有!我们只需要两个简单的渐变和一个mask-composite
来完成魔法。
让我们隔离<sh>
元素以了解那里发生了什么。
.box sh {
position: absolute;
inset: -150px;
border: 150px solid red;
background: lightblue;
border-radius: calc(150px + var(--r));
}
这是我们得到的结果
请注意,内部半径与主元素的border-radius
匹配。我定义了一个很大的边框 (150px
) 和一个border-radius
,该半径等于大的边框加上主元素的半径。在外部,我有一个等于150px + R
的半径。在内部,我有150px + R - 150px = R
。
我们必须隐藏内部(蓝色)部分,并确保边框(红色)部分仍然可见。为此,我定义了两个蒙版层 - 一个只覆盖内容框区域,另一个覆盖边框框区域(默认值)。然后,我将一个从另一个中排除,以显示边框。
mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
mask-composite: exclude;
我使用相同的技术来创建支持渐变和
。Ana Tudor 还有一篇关于border-radius
的边框蒙版合成
的好文章,我建议你阅读。
这种方法有什么缺点吗?
是的,这绝对不完美。你可能遇到的第一个问题与在主元素上使用边框有关。如果你不考虑这一点,可能会导致半径出现轻微的错位。我们的示例中存在这个问题,但也许你很难注意到它。
解决方法很简单:为<sh>
元素的inset
添加边框宽度。
.box {
--r: 50px;
border-radius: var(--r);
border: 2px solid;
}
.box sh {
position: absolute;
inset: -152px; /* 150px + 2px */
border: 150px solid #0000;
border-radius: calc(150px + var(--r));
}
另一个缺点是我们用于边框的大值 (150px
,在示例中)。该值应该足够大以包含阴影,但不能太大以避免溢出和滚动条问题。幸运的是,在线生成器
将考虑所有参数计算最佳值。
我所知的最后一个缺点是在使用复杂的border-radius
时。例如,如果你想对每个角应用不同的半径,则必须为每一边定义一个变量。我想这不算是一个缺点,但这会使你的代码维护起来有点困难。
.box {
--r-top: 10px;
--r-right: 40px;
--r-bottom: 30px;
--r-left: 20px;
border-radius: var(--r-top) var(--r-right) var(--r-bottom) var(--r-left);
}
.box sh {
border-radius: calc(150px + var(--r-top)) calc(150px + var(--r-right)) calc(150px + var(--r-bottom)) calc(150px + var(--r-left));
}
.box sh:before {
border-radius: var(--r-top) var(--r-right) var(--r-bottom) var(--r-left);
}
在线生成器
为了简单起见,只考虑统一的半径,但你现在知道如何修改代码,如果你想考虑复杂的半径配置。
总结
我们已经到了最后!渐变阴影背后的魔法不再是秘密。我试图涵盖所有可能性,以及你可能遇到的任何问题。如果我遗漏了什么,或者你发现任何问题,请随时在评论区报告,我会查看。
再次强调,考虑到实际解决方案将涵盖你的大多数用例,这其中很多可能过于复杂。然而,了解这个技巧背后的“为什么”和“如何”,以及如何克服它的局限性,这一点很重要。此外,我们得到了玩转 CSS 剪裁和蒙版的良好练习。
当然,你还有在线生成器
,你可以随时使用它来避免麻烦。
评论永久链接#
多年来,我多次遇到过在伪元素上进行基本变换会导致 z-index 出现问题的情况。我对 CSS 能力很有信心,并且对堆叠上下文有很好的理解,但是上面的句子就像一袋爆米花洒在我的大脑上一样;突然,我想起了所有可以使用这种解决方案的时刻。
总是很高兴学习一种新的方法来获得类似的结果。