使用 CSS 渐变创建阴影的不同方法

Avatar of Temani Afif
Temani Afif

DigitalOcean 为您旅程的每个阶段提供云产品。 立即开始使用 200 美元的免费积分!

我经常听到一个问题:是否可以用渐变而不是纯色来创建阴影? 没有特定的 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。 下面是一个图来说明多边形部分。

Showing the clip-path coordinates for the element.

蓝色区域是应用 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属性不考虑元素外部的区域来显示和隐藏事物。这就是为什么我被迫引入额外的元素 - 来模拟“外部”区域。

此外,请注意,我使用borderinset的组合来定义该区域。这使我能够保持额外元素的填充框与主元素相同,因此伪元素不需要额外的计算。

使用额外元素的另一个有用之处在于元素是固定的,只有伪元素在移动(使用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;

我使用相同的技术来创建支持渐变和border-radius的边框。Ana Tudor 还有一篇关于蒙版合成的好文章,我建议你阅读。

这种方法有什么缺点吗?

是的,这绝对不完美。你可能遇到的第一个问题与在主元素上使用边框有关。如果你不考虑这一点,可能会导致半径出现轻微的错位。我们的示例中存在这个问题,但也许你很难注意到它。

解决方法很简单:为<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 剪裁和蒙版的良好练习。

当然,你还有在线生成器,你可以随时使用它来避免麻烦。