使用单个元素创建 CSS 彩虹渐变无限符号

Avatar of Ana Tudor
Ana Tudor 发布

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

当我看到这个由 Infographic Paradise 设计的 渐变无限符号 Logo 时,我第一次萌生了用 CSS 实现类似效果的想法。

Original illustration. Shows a thick infinity symbol with a rainbow gradient filling its two loops and some highlights over this gradient.
最初的渐变无限符号。

经过四个小时二十多分钟,其中四个多小时都花在了调整位置、边缘和高光上……我终于得到了下面的结果。

Screenshot of my version. Shows a thick infinity symbol with a rainbow gradient filling its two loops and some highlights over this gradient.
我版本的彩虹渐变无限符号。

渐变看起来与原始插图中的不太一样,因为我选择以逻辑方式生成彩虹,而不是使用开发者工具拾色器或类似的东西,但除此之外,我认为我做得相当接近——所以让我们看看我是如何做到的!

标记

正如您可能已经从标题中猜到的那样,HTML 代码仅仅是一个元素。

<div class='∞'></div>

样式

确定方法

看到上面的效果时,首先想到的可能是使用圆锥渐变作为边框图像。 不幸的是,border-imageborder-radius 无法很好地协同工作,如下面的交互式演示所示。

查看 thebabydino 在 CodePen 上创建的 Pen@thebabydino)。

每当我们设置 border-image 时,border-radius 就会被忽略,因此遗憾的是无法将两者结合使用。

因此,我们这里采用的方法是使用 conic-gradient() 背景,然后借助 mask 去除中间部分。 让我们看看它是如何工作的!

创建两个 ∞ 形的一半

我们首先确定一个外径。

$do: 12.5em;

我们使用 .∞ 元素的 ::before::after 伪元素创建无限符号的两半。 为了将这两个伪元素并排放置,我们在它们的父元素(无限符号元素 .∞)上使用弹性布局。 每个伪元素的 widthheight 都等于外径 $do。 我们还使用 border-radius 的值为 50% 对它们进行圆角处理,并为它们提供一个虚拟 background 以便查看它们。

.∞ {
  display: flex;
	
  &:before, &:after {
    width: $do; height: $do;
    border-radius: 50%;
    background: #000;
    content: '';
  }
}

我们还通过使用弹性盒模型方法将 .∞ 元素垂直和水平居中于其父元素(在本例中为 body)。

查看 thebabydino 在 CodePen 上创建的 Pen@thebabydino)。

圆锥渐变 () 的工作原理

为了为这两个半部分创建 conic-gradient() 背景,我们必须首先了解 conic-gradient() 函数的工作原理。

如果在 conic-gradient() 函数内部,我们有一系列没有明确位置的停止点,则第一个停止点被认为在 0%(或 0deg,两者相同)处,最后一个停止点被认为在 100%(或 360deg)处,而其余所有停止点则均匀分布在 [0%, 100%] 区间内。

查看 thebabydino 在 CodePen 上创建的 Pen@thebabydino)。

如果我们只有 2 个停止点,则很简单。 第一个在 0% 处,第二个(也是最后一个)在 100% 处,并且中间没有其他停止点。

如果我们有 3 个停止点,则第一个在 0% 处,最后一个(第三个)在 100% 处,而第二个则位于 [0%, 100%] 区间的正中间,即 50% 处。

如果我们有 4 个停止点,则第一个在 0% 处,最后一个(第四个)在 100% 处,而第二个和第三个将 [0%, 100%] 区间分成 3 个相等的部分,分别位于 33.(3)%66.(6)% 处。

如果我们有 5 个停止点,则第一个在 0% 处,最后一个(第五个)在 100% 处,而第二个、第三个和第四个将 [0%, 100%] 区间分成 4 个相等的部分,分别位于 25%50%75% 处。

如果我们有 6 个停止点,则第一个在 0% 处,最后一个(第六个)在 100% 处,而第二个、第三个、第四个和第五个将 [0%, 100%] 区间分成 5 个相等的部分,分别位于 20%40%60%80% 处。

一般来说,如果我们有 n 个停止点,则第一个在 0% 处,最后一个在 100% 处,而中间的停止点将 [0%, 100%] 区间分成 n-1 个相等的部分,每个部分的跨度为 100%/(n-1)。 如果我们给停止点 0 为基的索引,则每个停止点都位于 i*100%/(n-1) 处。

对于第一个停止点,i0,这将得到 0*100%/(n-1) = 0%

对于最后一个(第 n 个)停止点,in-1,这将得到 (n-1)*100%/(n-1) = 100%

这里,我们选择使用 9 个停止点,这意味着我们将 [0%, 100%] 区间分成 8 个相等的部分。

好的,但是我们如何获得停止点列表呢?

hsl() 停止点

嗯,为了简单起见,我们选择将其生成为 HSL 值列表。 我们保持 饱和度亮度 固定,并改变 色相。 色相是一个角度值,范围从 0360,我们可以在此处看到。

Hue scale from 0 to 360 in the HSB/HSL models.
色相刻度从 0360 的可视化表示(饱和度和亮度保持不变)。

考虑到这一点,如果我们知道**起始色相** $hue-start、**色相范围** $hue-range(这是结束色相减去起始色相)和**停止点数** $num-stops,我们就可以构建一个具有固定饱和度和亮度以及变化色相的 hsl() 停止点列表。

假设我们将饱和度和亮度分别固定在 85%57%(任意值,可能可以调整以获得更好的结果),并且例如,我们可以从 240 的起始色相到 300 的结束色相,并使用 4 个停止点。

为了生成此停止点列表,我们使用一个 get-stops() 函数,该函数将这三件事作为参数。

@function get-stops($hue-start, $hue-range, $num-stops) {}

我们创建停止点列表 $list,该列表最初为空(并在填充后在最后返回)。 我们还计算停止点将完整起始到结束区间分成相等区间中的一个区间的跨度($unit)。

@function get-stops($hue-start, $hue-range, $num-stops) {
  $list: ();
  $unit: $hue-range/($num-stops - 1);
	
  /* populate the list of stops $list */
	
  @return $list
}

为了填充我们的 $list,我们遍历停止点,计算当前色相,使用当前色相生成该停止点处的 hsl() 值,然后将其添加到停止点列表中。

@for $i from 0 to $num-stops {
  $hue-curr: $hue-start + $i*$unit;
  $list: $list, hsl($hue-curr, 85%, 57%);
}

我们现在可以使用此函数返回的停止点列表用于任何类型的渐变,这可以从下面交互式演示中显示的此函数的用法示例中看出(导航既可以通过使用侧面的上一个/下一个按钮,也可以通过使用方向键和 PgDn/PgUp 键)。

查看 thebabydino 在 CodePen 上创建的 Pen@thebabydino)。

请注意,当我们的范围超出 [0, 360] 区间的某个端点时,它将从另一端继续。 例如,当起始色相为 30 且范围为 -210(第四个示例)时,我们只能下降到 0,然后我们从 360 继续下降。

用于两个半圆的圆锥渐变

好的,但是我们如何确定特定情况下的$hue-start$hue-range呢?

在原始图像中,我们在循环两个半圆的中心点之间画一条线,然后从这条线开始,在两种情况下都顺时针方向观察,我们看到了起点和终点在[0, 360]色相区间的位置,以及我们经过的其他色相。

Original illustration, annotated. We've marked out the central points of the two halves, connected them with a line and used this line as the start for going around each of the two halves in the clockwise direction.
我们从连接两个半圆中心点的线开始,并沿顺时针方向绕它们旋转。

为了简化问题,我们认为在沿着我们的无限符号移动时,我们经过了整个[0, 360]色相范围。这意味着每个半圆的范围在绝对值上为180360的一半)。

Hue scale from 0 to 360 in the HSB/HSL models, with saturation and lightness fixed at 100% and 50% respectively. Red corresponds to a hue of 0/ 360, yellow to a hue of 60, lime to a hue of 120, cyan to a hue of 180, blue to a hue of 240, magenta to a hue of 300.
当饱和度和亮度分别固定在100%50%时,关键字与色相值的对应关系。

在左侧半圆,我们从看起来介于某种青色(色相180)和某种青柠色(色相120)之间的颜色开始,因此我们将起始色相取为这两个色相的平均值(180 + 120)/2 = 150

Original illustration, annotated. For the left half, our start hue is 150 (something between a kind of cyan and a kind of lime), we pass through yellows, which are around 60 in hue and end up at a kind of red, 180 away from the start, so at 330.
左侧半圆的方案。

我们得到某种红色,它与起始值的距离为180,因此在330处,无论是减去还是加上180

(150 - 180 + 360)%360 = (150 + 180 + 360)%360 = 330

那么……我们是向上还是向下?好吧,我们经过了黄色,黄色在色相标尺上大约为60,所以是从150向下,而不是向上。向下意味着我们的范围为负(-180)。

Original illustration, annotated. For the right half, our start hue is 150 (something between a kind of cyan and a kind of lime), we pass through blues, which are around 240 in hue and end up at a kind of red, 180 away from the start, so at 330.
右侧半圆的方案。

在右侧半圆,我们也从青色青柠色之间的相同色相(150)开始,也以相同的红色(330)结束,但这次我们经过了蓝色,蓝色大约为240,这意味着我们从起始色相150向上,因此在这种情况下我们的范围为正(180)。

就停止次数而言,9次应该足够了。

现在使用左侧半圆的值作为我们函数的默认值,更新我们的代码。

@function get-stops($hue-start: 150, $hue-range: -180, $num-stops: 9) {
  /* same as before */
}

.∞ {
  display: flex;
	
  &:before, &:after {
    /* same as before */
    background: conic-gradient(get-stops());
  }
  
  &:after {
    background: conic-gradient(get-stops(150, 180));
  }
}

现在我们的两个圆盘都有了conic-gradient()背景。

查看thebabydino在CodePen上创建的Pen@thebabydino)。

但是,我们不希望这些圆锥渐变从顶部开始。

对于第一个圆盘,我们希望它从右侧开始——在顺时针(正)方向上,从顶部算起90°。对于第二个圆盘,我们希望它从左侧开始——在另一个(负)方向上,从顶部算起90°,这相当于在顺时针方向上从顶部算起270°(因为由于某些原因,负角度似乎不起作用)。

The conic gradient for the first (left) half starts from the right, which means an offset of 90° in the clockwise (positive) direction from the top. The conic gradient for the second (right) half starts from the left, which means an offset of 270° in the clockwise (positive) direction (and of 90° in the negative direction) from the top.
我们两个半圆相对于顶部的角度偏移。

让我们修改我们的代码来实现这一点。

.∞ {
  display: flex;
	
  &:before, &:after {
    /* same as before */
    background: conic-gradient(from 90deg, get-stops());
  }
  
  &:after {
    background: conic-gradient(from 270deg, get-stops(150, 180));
  }
}

到目前为止,一切顺利!

查看thebabydino在CodePen上创建的Pen@thebabydino)。

从🥧到🍩

下一步是从我们的两个半圆中挖出孔。我们使用mask或更准确地说,使用radial-gradient()来实现。这暂时切断了对Edge的支持,但由于它正在开发中,因此它可能在不久的将来成为跨浏览器解决方案。

请记住,CSS渐变蒙版默认情况下是alpha蒙版(并且只有Firefox目前允许通过mask-mode更改此设置),这意味着只有alpha通道起作用。将mask叠加在我们元素之上,使该元素的每个像素都使用mask相应像素的alpha通道。如果mask像素完全透明(其alpha值为0),则元素的相应像素也将是透明的。

查看thebabydino在CodePen上创建的Pen@thebabydino)。

为了创建mask,我们计算外半径$ro(外直径$do的一半)和内半径$ri(外半径$ro的一部分)。

$ro: .5*$do;
$ri: .52*$ro;
$m: radial-gradient(transparent $ri, red 0);

然后,我们在两个半圆上设置mask

.∞ {
  /* same as before */
	
  &:before, &:after {
    /* same as before */
    mask: $m;
  }
}

查看thebabydino在CodePen上创建的Pen@thebabydino)。

在Firefox中,这看起来完美无缺,但在Chrome中,从一个停止点到另一个停止点的径向渐变的边缘看起来很丑,因此我们的环的内边缘也一样。

Screenshot. Shows a close-up of the inner edge of the right half in Chrome. These inner edges look jagged and ugly in Chrome.
Chrome中右侧半圆内边缘的特写镜头。

这里的解决方法不是在停止点之间进行突然的过渡,而是在一小段距离内将其展开,比如半个像素。

$m: radial-gradient(transparent calc(#{$ri} - .5px), red $ri);

现在我们已经消除了Chrome中的锯齿状边缘

Screenshot. Shows a close-up of the inner edge of the right half in Chrome after spreading out the transition between stops over half a pixel. These inner edges now look blurry and smoother in Chrome.
在停止点之间将过渡展开半个像素后,Chrome中右侧半圆内边缘的特写镜头。

下一步是偏移这两个半圆,使它们实际上形成一个无限符号。可见的圆形条带都具有相同的宽度,即外半径$ro和内半径$ri之间的差值。这意味着我们需要将每个半圆横向移动这个差值的一半$ri - $ri

.∞ {
  /* same as before */
	
  &:before, &:after {
    /* same as before */
    margin: 0 (-.5*($ro - $ri));
  }
}

查看thebabydino在CodePen上创建的Pen@thebabydino)。

相交的半圆

我们越来越接近了,但这里仍然存在一个非常大的问题。我们不希望循环的右侧完全覆盖左侧。相反,我们希望右侧的顶部覆盖左侧的顶部,左侧的底部覆盖右侧的底部。

那么我们如何实现这一点呢?

我们采用与一篇较早的文章中提出的方法类似的方法:使用3D!

为了更好地理解它是如何工作的,请考虑下面的两个卡片示例。当我们围绕它们的x轴旋转它们时,它们不再位于屏幕平面上了。正旋转使底部向前,并将顶部向后推。负旋转使顶部向前,并将底部向后推。

查看thebabydino在CodePen上创建的Pen@thebabydino)。

请注意,上面的演示在Edge中不起作用。

因此,如果我们对左侧进行正旋转,对右侧进行负旋转,那么右侧的顶部将显示在左侧的顶部前面,反之亦然,底部也是如此。

添加perspective使离我们眼睛更近的部分看起来更大,离我们更远的部分看起来更小,我们使用更小的角度。如果没有它,我们将拥有3D平面相交,但没有3D外观。

请注意,我们的两个半圆都需要在同一个3D上下文中,这可以通过在.∞元素上设置transform-style: preserve-3d来实现。

.∞ {
  /* same as before */
  transform-style: preserve-3d;
	
  &:before, &:after {
    /* same as before */
    transform: rotatex(1deg);
  }
  
  &:after {
    /* same as before */
    transform: rotatex(-1deg);
  }
}

现在我们快完成了,但还没有完全完成。

查看thebabydino在CodePen上创建的Pen@thebabydino)。

微调

中间有一条略带红色的条带,因为渐变的结束和交叉线并不完全匹配。

Screenshot. Shows a close-up of the intersection of the two halves. In theory, the intersection line should match the start/ end line of the conic gradients, but this isn't the case in practice, so we're still seeing a strip of red along it, even though the red side should be behind the plane of the screen and not visible.
两个半圆交叉处的小问题的特写镜头。

一个非常丑陋但有效的解决方法是在右侧(::after伪元素)的旋转之前添加1px的平移。

.∞:after { transform: translate(1px) rotatex(-1deg) }

好多了!

查看thebabydino在CodePen上创建的Pen@thebabydino)。

但这仍然不完美。由于我们两个环的内边缘有点模糊,因此它们与清晰的外边缘之间的过渡看起来有点奇怪,所以也许我们可以做得更好。

Screenshot. Shows a close-up of the area around the intersection of the two halves, where the crisp outer edges meet the blurry inner ones, which looks odd.
连续性问题(清晰的外边缘与模糊的内边缘相遇)的特写镜头。

这里的快速解决方法是在两个半圆的每个半圆上添加一个radial-gradient()覆盖。对于两个半圆的大部分未遮罩部分,此覆盖是透明的白色(rgba(#fff, 0)),并在它们的内边缘和外边缘都变为纯白色(rgba(#fff, 1)),这样我们就可以获得良好的连续性。

$gc: radial-gradient(#fff $ri, rgba(#fff, 0) calc(#{$ri} + 1px), 
  rgba(#fff, 0) calc(#{$ro} - 1px), #fff calc(#{$ro} - .5px));

.∞ {
  /* same as before */
	
  &:before, &:after {
    /* same as before */
    background: $gc, conic-gradient(from 90deg, get-stops());
  }
  
  &:after {
    /* same as before */
    background: $gc, conic-gradient(from 270deg, get-stops(150, 180));
  }
}

一旦我们将深色background添加到body中,好处就会变得更加明显。

查看thebabydino在CodePen上创建的Pen@thebabydino)。

现在即使放大也看起来更好了。

Screenshot. Shows a close-up of the area around the intersection of the two halves, we don't have the same sharp contrast between inner and outer edges, not even when zooming in.
内边缘和外边缘之间不再有明显的对比。

最终结果

最后,我们通过在两个半圆上叠加一些更细微的径向渐变高光来添加一些美化效果。这部分花费了我最多时间,因为它涉及最少的逻辑和最多的反复试验。在这一点上,我只是将原始图像叠加在.∞元素下方,使这两个半圆半透明,并开始添加渐变并进行调整,直到它们与高光基本匹配。你可以看到我什么时候厌倦了,因为那时位置值变成了更粗略的近似值,小数位数更少。

另一个很酷的点子是在整个页面上使用body上的filter添加投影。遗憾的是,这会破坏 Firefox 中的 3D 交叉效果,这意味着我们也不能在那里添加它。

@supports not (-moz-transform: scale(2)) {
  filter: drop-shadow(.25em .25em .25em #000) 
          drop-shadow(.25em .25em .5em #000);
}

现在我们得到了最终的静态结果!

查看 thebabydino 在 CodePen 上创建的 @thebabydinoPen

用动画来增强它!

当我第一次分享这个演示时,有人问我是否可以对其进行动画处理。我最初认为这会很复杂,但后来我意识到,多亏了 Houdini,它并不需要那么复杂!

正如我在之前的文章中提到的,我们可以对渐变色停止点之间的颜色进行动画处理,例如从红色到蓝色。在我们的例子中,用于生成彩虹渐变的hsl()值的饱和度和亮度分量保持不变,唯一变化的是色相。

对于每一个停止点,色相从其初始值变为其初始值加上360,从而在此过程中遍历整个色相范围。这相当于保持初始色相不变,并改变一个偏移量。这个偏移量--off是我们进行动画处理的自定义属性。

遗憾的是,这意味着支持仅限于启用了**实验性 Web 平台功能**标志的 Blink 浏览器。虽然从 Chrome 69+ 开始,conic-gradient() 无需标志即可原生支持,但 Houdini 却不行,所以在这种特定情况下,目前还没有真正的收获。

Screenshot showing the Experimental Web Platform features flag being enabled in Chrome.
在 Chrome 中启用了实验性 Web 平台功能标志。

尽管如此,让我们看看如何将其全部写成代码!

首先,我们修改get-stops()函数,以便在任何时间点的当前色相都是当前停止点的初始色相$hue-curr加上我们的偏移量--off

$list: $list, hsl(calc(#{$hue-curr} + var(--off, 0)), 85%, 57%);

接下来,我们注册这个自定义属性。

CSS.registerProperty({
  name: '--off', 
  syntax: '<number>', 
  initialValue: 0, 
  inherits: true
})

请注意,现在需要inherits,即使它在规范的早期版本中是可选的。

最后,我们将其动画设置为360

.∞ {
  /* same as before */
	
  &:before, &:after {
    /* same as before */
    animation: shift 2s linear infinite;
  }
}

@keyframes shift { to { --off: 360 } }

这为我们提供了无限循环的动画渐变!

动画 ∞ 徽标(实时演示,仅限于启用了标志的 Blink 浏览器)。

就是这样!我希望您喜欢这次深入了解如今 CSS 可以做什么的体验!