CSS 奥运五环

Avatar of Amit Sheen
Amit Sheen

DigitalOcean 提供适合您旅程各个阶段的云产品。立即开始使用 $200 免费积分!

几年前,在 2020 年东京奥运会期间,我制作了一个动画 3D 奥运五环的演示。我很喜欢它,它看起来很棒,而且我喜欢五环互相交叉的效果。

但是代码本身有点旧。我用 SCSS 编写了它,而且写得很糟糕。我知道它可以做得更好,至少可以符合现代标准。

所以,我决定以今年的奥运会为契机,从头开始重新制作这个演示。这一次我使用的是原生 CSS,利用了现代特性,例如 三角函数 来减少魔数,以及 相对颜色语法 来更好地管理颜色。最重要的是,事实证明,新的演示比我在 2020 年编写的旧 SCSS 版本更高效,代码行更少!

再次看看第一个演示中的 CSS 选项卡,因为我们最终将使用我们将一起使用的方法得到完全不同且更好的东西。那么,让我们开始吧!

标记

我们将使用图层来创建 3D 效果。这些图层一个接一个地定位(在 z 轴上)以获得 3D 对象的深度,在本例中为圆环。每个图层的形状、大小和颜色的组合——以及它们在图层之间变化的方式——是创建完整的 3D 对象的关键。

在本例中,我使用了 16 个图层,每个图层都有不同的阴影(较暗的图层堆叠在后面)以获得简单的灯光效果,并使用每个图层的尺寸和厚度来建立圆形形状。

就 HTML 而言,我们需要五个 <div> 元素,每个元素代表一个圆环,其中每个 <div> 包含 16 个元素,这些元素充当图层,我将它们包装在 <i> 标签中。我们将把这五个圆环放在一个父容器中,以将它们组合在一起。我们将为父容器命名为 .rings 类,并为每个圆环命名为 .ring 类。

这是一个简化的 HTML 版本,展示了它是如何组合在一起的


<div class="rings">
  <div class="ring">
    <i style="--i: 1;"></i>
    <i style="--i: 2;"></i>
    <i style="--i: 3;"></i>
    <i style="--i: 4;"></i>
    <i style="--i: 5;"></i>
    <i style="--i: 6;"></i>
    <i style="--i: 7;"></i>
    <i style="--i: 8;"></i>
    <i style="--i: 9;"></i>
    <i style="--i: 10;"></i>
    <i style="--i: 11;"></i>
    <i style="--i: 12;"></i>
    <i style="--i: 13;"></i>
    <i style="--i: 14;"></i>
    <i style="--i: 15;"></i>
    <i style="--i: 16;"></i>
  </div>

  <!-- 4 more rings... -->  

</div>

请注意我在每个 <i> 元素的 style 属性中添加的 --i 自定义属性

<i style="--i: 1;"></i>
<i style="--i: 2;"></i>
<i style="--i: 3;"></i>
<!-- etc. -->

我们将使用 --i 来计算每个图层的位置、大小和颜色。这就是为什么我将它们的值设置为按升序排列的整数——这些将作为对每个图层进行单独排列和设置样式的乘数。

专业提示:如果您使用的 IDE 支持 Emmet,则可以避免手动编写每个图层的 HTML。但如果不是,也不用担心,因为 CodePen 也可以! 在您的 HTML 编辑器中输入以下内容,然后按键盘上的 Tab 键将其扩展为 16 个图层:i*16[style="--i: $;"]

(原生) CSS

让我们从父 .rings 容器开始,现在它将只获得相对定位。如果没有相对定位,当在圆环上设置绝对定位时,它们将从文档流中删除,最终会出现在页面上的某个位置。

.rings {
  position: relative;
}

.ring {
  position: absolute;
}

让我们对 <i> 元素执行相同的操作,但使用 CSS 嵌套 来保持代码简洁。我们也会在操作过程中添加 border-radius 来剪切方框边缘以形成完美的圆圈。

.rings {
  position: relative;
}

.ring {
  position: absolute;
  
  i {
    position: absolute;
    border-radius: 50%;
  }
}

在我们继续之前,我们将应用的最后一段基本样式是 --ringColor 的自定义属性。这将使圆环的着色非常简单,因为我们只需编写一次,然后就可以逐层覆盖它。我们正在 border 属性上声明 --ringColor,因为我们只想在每个图层的外部边缘进行着色,而不是使用 background-color 将它们完全填充。

.rings {
  position: relative;
}

.ring {
  position: absolute;
  --ringColor: #0085c7;
  
  i {
    position: absolute;
    inset: -100px;
    border: 16px var(--ringColor) solid;
    border-radius: 50%;
  }
}

您是否注意到我在那里偷偷加了一些东西?没错,inset 属性也在那里,并且设置为 100px 的负值。这可能看起来有点奇怪,所以让我们在继续设置样式之前先谈谈它。

负内边距

inset 属性上设置负值意味着图层的位置落在 .ring 元素的外部。因此,我们可以将其更多地视为“外边距”而不是“内边距”。在我们的例子中,.ring 没有尺寸,因为没有内容或 CSS 属性来赋予它尺寸。这意味着图层的 inset(或更确切地说是“外边距”)在每个方向上都是 100px,导致 .ring 为 200×200 像素。

A blue-bordered transparent square drawn on top of graph lines with arrows inside indicating the ring layer offsets and how they affect the size of the ring element.

让我们检查一下到目前为止我们所做的工作

定位以获得深度

我们使用图层来营造深度感。我们通过沿着 z 轴定位 16 个图层中的每一个来实现这一点,这将元素从前到后堆叠起来。我们将每个图层间隔 2px——这正是我们需要创建每个图层之间轻微的视觉分离所需的空间,从而获得我们想要的深度。

还记得我们在 HTML 中使用的 --i 自定义属性吗?

<i style="--i: 1;"></i>
<i style="--i: 2;"></i>
<i style="--i: 3;"></i>
<!-- etc. -->

同样,这些是帮助我们沿 z 轴 translate 每个图层的乘数。让我们创建一个新的自定义属性来定义该方程,以便我们可以将其应用于每个图层

i {
  --translateZ: calc(var(--i) * 2px);
}

我们将其应用于什么?我们可以使用 CSS transform 属性。这样,我们可以在沿 z 轴平移它们的同时垂直旋转图层(即 rotateY()

i {
  --translateZ: calc(var(--i) * 2px);

  transform: rotateY(-45deg) translateZ(var(--translateZ));
}

用于阴影的颜色

对于颜色阴影,我们将根据图层的位置对其进行加深,以便从 z 轴的前面到后面,图层会变得越来越暗。有几种方法可以做到这一点。一种方法是添加一个黑色图层,并逐渐降低其不透明度。另一种方法是在 hsl() 颜色函数中修改“亮度”通道,其中值在前面“更亮”,并向后逐渐变暗。第三种方法是调整图层的不透明度,但这会变得很乱。

尽管我们有这三种方法,但我认为现代 CSS 相对颜色语法是最好的选择。我们已经定义了默认的 --ringColor 自定义属性。我们可以将其通过相对颜色语法转换成其他颜色,用于每个圆环 <i> 图层。

首先,我们需要一个新的自定义属性,我们可以用来计算“光”值

.ring {
  --ringColor: #0085c7;
  
  i {
    --light: calc(var(--i) / 16);

    border: 16px var(--ringColor) solid;
  }
}

我们将使用 calc() 计算的结果在另一个自定义属性中,将我们的默认 --ringColor 通过相对颜色语法,其中 --light 自定义属性有助于修改结果颜色的亮度。

.ring {
  --ringColor: #0085c7;
  
  i {
    --light: calc(var(--i) / 16);
    --layerColor: rgb(from var(--ringColor) calc(r * var(--light)) calc(g * var(--light)) calc(b * var(--light)));

    border: 16px var(--ringColor) solid;
  }
}

这是一个相当复杂的方程!但它看起来很复杂只是因为相对颜色语法需要每个颜色通道(RGB)的参数,而我们正在计算每一个参数。

rgb(from origin-color channelR channelG channelB)

就计算而言,我们将每个 RGB 通道乘以 --light 自定义属性,该属性是 01 之间的数字,除以图层数 16

是时候再次检查一下我们的进展了

创建形状

为了获得圆环形状,我们将使用 border 属性来设置图层的大小(即厚度)。这里我们可以开始在我们的工作中使用三角函数!

我们希望每个圆环的厚度在 0deg180deg 之间——因为我们实际上只制作了半圆——所以我们将 180deg 除以图层数 16,结果为 11.25deg。使用 sin() 三角函数(等效于直角的对边斜边),我们得到了图层的 --size 的表达式

--size: calc(sin(var(--i) * 11.25deg) * 16px);

因此,无论 HTML 中的 --i 是什么,它都作为计算图层 border 厚度的乘数。我们一直在像这样声明图层的边框

i {
  border: 16px var(--ringColor) solid;
)

现在我们可以用 --size 计算结果来替换硬编码的 16px

i {
  --size: calc(sin(var(--i) * 11.25deg) * 16px);

  border: var(--size) var(--layerColor) solid;
)

但是!正如您可能已经注意到的,当我们更改图层的 border 宽度时,我们并没有改变图层的大小。因此,圆形轮廓只出现在图层的内侧。这里的关键是了解使用 inset 属性设置 --size,这意味着它不会影响 元素的 box-sizing。结果当然是一个 3D 圆环,但大多数阴影都被掩盖了。

⚠️ 自动播放媒体

我们可以通过为每一层计算新的 `inset` 来使阴影突出显示。这有点类似于我在 2020 年版本中所做的,但我认为我找到了一个更简单的方法:添加一个具有相同 `border` 值的 `outline`,以在环的外侧完成圆弧。

i {
  --size: calc(sin(var(--i) * 11.25deg) * 16px);

  border: var(--size) var(--layerColor) solid;
  outline: var(--size) var(--layerColor) solid;
}

现在我们已经建立了 `outline`,我们有了看起来更自然的环。

动画环

我必须在最后一个演示中为环添加动画,以比较环在添加阴影之前和之后的阴影。我们将使用相同的动画在最终演示中,因此在我们向 HTML 添加其他四个环之前,让我们先分解一下我是如何做到这一点的。

我并不试图做任何花哨的事情;我只是将 y 轴上的旋转从 `-45deg` 设置为 `45deg`(`translateZ` 值保持不变)。

@keyframes ring {
  from { transform: rotateY(-45deg) translateZ(var(--translateZ, 0)); }
  to { transform: rotateY(45deg) translateZ(var(--translateZ, 0)); }
}

至于 `animation` 属性,我给它命名为 `ring`,并硬编码(至少现在是)一个 `3s` 的持续时间,该持续时间无限循环。分别使用 `ease-in-out` 和 `alternate` 设置动画的计时函数,可以获得平滑的来回运动。

i {
  animation: ring 3s infinite ease-in-out alternate;
}

这就是动画的工作原理!

添加更多环

现在我们可以将剩余的四个环添加到 HTML 中。记住,我们总共有五个环,每个环包含 16 个 `<i>` 层。它看起来可能像这样简单

<div class="rings">
  <div class="ring"> <!-- <i> layers --> </div>
  <div class="ring"> <!-- <i> layers --> </div>
  <div class="ring"> <!-- <i> layers --> </div>
  <div class="ring"> <!-- <i> layers --> </div>
  <div class="ring"> <!-- <i> layers --> </div>
</div>

这种标记的简洁性中蕴含着优雅。我们可以使用 CSS 的 `nth-child()` 伪选择器单独选择它们。我喜欢比这更具声明性的方式,并将为每个 `.ring` 和额外的类提供一个我们可以用来明确选择给定环的类。

<div class="rings">
  <div class="ring ring__1"> <!-- layers --> </div>
  <div class="ring ring__2"> <!-- layers --> </div>
  <div class="ring ring__3"> <!-- layers --> </div>
  <div class="ring ring__4"> <!-- layers --> </div>
  <div class="ring ring__5"> <!-- layers --> </div>
</div>

我们现在需要单独调整每个环。现在,一切都看起来像我们一起制作的第一个环。我们将使用我们刚刚在 HTML 中设置的唯一类来赋予它们自己的颜色、位置和动画持续时间。

好消息是?我们一直在使用自定义属性!我们所要做的就是更新每个环唯一类中的值。

.ring {
  &.ring__1 { --ringColor: #0081c8; --duration: 3.2s; --translate: -240px, -40px; }
  &.ring__2 { --ringColor: #fcb131; --duration: 2.6s; --translate: -120px, 40px; }
  &.ring__3 { --ringColor: #444444; --duration: 3.0s; --translate: 0, -40px; }
  &.ring__4 { --ringColor: #00a651; --duration: 3.4s; --translate: 120px, 40px; }
  &.ring__5 { --ringColor: #ee334e; --duration: 2.8s; --translate: 240px, -40px; }
}

如果你想知道这些 `--ringColor` 值来自哪里,我是根据 国际奥委会公布的颜色 来确定的。每个 `--duration` 彼此略微偏移,以错开环之间的运动,并且环被 `--translate` 到了 `120px` 的距离,然后通过交替将它们的位置设置为 `40px` 和 `-40px` 来垂直错开。

让我们将平移内容应用到 `.ring` 元素中。

.ring {
  transform: translate(var(--translate));
}

之前,我们将动画的持续时间设置为硬编码的 3 秒。

i {
  animation: ring 3s infinite ease-in-out alternate;
}

现在是时候用一个自定义属性来替换它,该属性可以单独计算每个环的持续时间。

i {
  animation: ring var(--duration) -10s infinite ease-in-out alternate;
}

哇,哇!`-10s` 值在那里做什么?即使每个环层被设置为以不同的持续时间进行动画,动画的起始角度也是相同的。在改变持续时间时添加一个恒定的负延迟将确保每个环的动画从不同的角度开始。

现在我们有了几乎完成的东西。

最后润色

我们已经到了最后阶段!动画看起来很棒,但我还想再添加两件事。第一个是在父 `.rings` 容器的 x 轴上添加一个小 `-10deg` 的“倾斜”。这将使它看起来像是我们从更高的角度观看物体。

.rings {
  rotate: x -10deg;
}

第二个收尾工作与阴影有关。我们可以真正突出我们作品的 3D 深度,只需要选择 `.ring` 元素的 `::after` 伪元素并将其设置为类似于阴影的样式。

首先,我们将伪元素的边框和轮廓的宽度设置为一个常数(`24px`),同时将颜色设置为半透明黑色(`#0003`)。然后我们将它们 `translate`,使它们看起来更远。我们还将它们 `inset`,使它们与实际的环对齐。基本上,我们正在相对于实际元素移动伪元素。

.ring {
  /* etc. */

  &::after {
    content: '';
    position: absolute;
    inset: -100px;
    border: 24px #0003 solid;
    outline: 24px #0003 solid;
    translate: 0 -100px -400px;
  }
}

伪元素现在看起来不太像阴影。但是如果我们稍微 `blur()` 一下,它们就会变成阴影。

.ring {
  /* etc. */

  &::after {
    content: '';
    position: absolute;
    inset: -100px;
    border: 24px #0003 solid;
    outline: 24px #0003 solid;
    translate: 0 -100px -400px;
    filter: blur(12px);
  }
}

阴影也很方。让我们确保它们像环一样圆。

.ring {
  /* etc. */

  &::after {
    content: '';
    position: absolute;
    inset: -100px;
    border: 24px #0003 solid;
    outline: 24px #0003 solid;
    translate: 0 -100px -400px;
    filter: blur(12px);
    border-radius: 50%;
  }
}

哦,我们应该为伪元素设置相同的动画,这样阴影就能与环一起移动。

.ring {
  /* etc. */

  &::after {
    content: '';
    position: absolute;
    inset: -100px;
    border: 24px #0003 solid;
    outline: 24px #0003 solid;
    translate: 0 -100px -400px;
    filter: blur(12px);
    border-radius: 50%;
    animation: ring var(--duration) -10s infinite ease-in-out alternate;
  }
}

最终演示

让我们停下来欣赏我们完成的作品。

最终,我对 2024 年版本的奥林匹克环非常满意。2020 年版本完成了工作,并且可能是当时正确的做法。但是,随着我们今天在现代 CSS 中获得的所有功能,我有许多机会改进代码,使其不仅更高效,而且更可重用——例如,这可以在另一个项目中使用,并且只需更新 `--ringColor` 自定义属性即可“主题化”。

最终,这个练习向我证明了现代 CSS 的强大功能和灵活性。我们利用一个现有的、复杂的想法,并用简单而优雅的方式重新创建了它。