CSS Olympic Rings | CSS-Tricks

contains 16 elements that act as the layers, which I’m wrapping in tags. Those five rings we’ll put in a parent container to hold things together. We’ll give the parent container a .rings class and each ring, creatively, a .ring class.

This is an abbreviated version of the HTML showing how that comes together:


Note the --i custom property I’ve dropped on the style attribute of each element:




We’re going to use --i to calculate each layer’s position, size, and color. That’s why I’ve set their values as integers in ascending order — those will be multipliers for arranging and styling each layer individually.

Pro tip: You can avoid writing the HTML for each and every layer by hand if you’re working on an IDE that supports Emmet. But if not, no worries, because CodePen does! Enter the following into your HTML editor then press the Tab key on your keyboard to expand it into 16 layers: i*16[style="--i: $;"]

The (vanilla) CSS

Let’s start with the parent .rings container for now will just get a relative position. Without relative positioning, the rings would be removed from the document flow and wind up off the page somewhere when setting absolute positioning on them.

.rings {
  position: relative;
}

.ring {
  position: absolute;
}

Let’s do the same with the elements, but use CSS nesting to keep the code compact. We’ll throw in border-radius while we’re at it to clip the boxy edges to form perfect circles.

.rings {
  position: relative;
}

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

The last piece of basic styling we’ll apply before moving on is a custom property for the --ringColor. This’ll make coloring the rings fairly straightforward because we can write it once, and then override it on a layer-by-layer basis. We’re declaring --ringColor on the border property because we only want coloration on the outer edges of each layer rather than filling them in completely with background-color:

.rings {
  position: relative;
}

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

Did you notice I snuck something else in there? That’s right, the inset property is also there and set to a negative value of 100px. That might look a little strange, so let’s talk about that first as we continue styling our work.

Negative insetting

Setting a negative value on the inset property means that the layer’s position falls outside the .ring element. So, we might think of it more like an “outset” instead. In our case, the .ring has no size as there are no content or CSS properties to give it dimensions. That means the layer’s inset (or rather “outset”) is 100px in each direction, resulting in a .ring that is 200×200 pixels.

Let’s check in with what we have so far:

Positioning for depth

We’re using the layers to create the impression of depth. We do that by positioning each of the 16 layers along the z-axis, which stacks elements from front to back. We’ll space each one a mere 2px apart — that’s all the space we need to create a slight visual separation between each layer, giving us the depth we’re after.

Remember the --i custom property we used in the HTML?




Again, those are multipliers to help us translate each layer along the z-axis. Let’s create a new custom property that defines the equation so we can apply it to each layer:

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

What do we apply it to? We can use the CSS transform property. This way, we can rotate the layers vertically (i.e., rotateY()) while translating them along the z-axis:

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

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

Color for shading

For color shading, we’ll darken the layers according to their position so that the layers get darker as we move from the front of the z-axis to the back. There are a few ways to do it. One is dropping in another black layer with decreasing opacity. Another is modifying the “lightness” channel in a hsl() color function where the value is “lighter” up front and incrementally darker towards the back. A third option is playing with the layer’s opacity, but that gets messy.

Even though we have those three approaches, I think the modern CSS relative color syntax is the best way to go. We’ve already defined a default --ringColor custom property. We can put it through the relative color syntax to manipulate it into other colors for each ring layer.

First, we need a new custom property we can use to calculate a “light” value:

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

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

We’ll use the calc()-ulated result in another custom property that puts our default --ringColor through the relative color syntax where the --light custom property helps modify the resulting color’s lightness.

.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;
  }
}

That’s quite an equation! But it only looks complex because the relative color syntax needs arguments for each channel in the color (RGB) and we’re calculating each one.

rgb(from origin-color channelR channelG channelB)

As far as the calculations go, we multiply each RGB channel by the --light custom property, which is a number between 0 and 1 divided by the number of layers, 16.

Time for another check to see where we’re at:

Creating the shape

To get the circular ring shape, we’ll set the layer’s size (i.e., thickness) with the border property. This is where we can start using trigonometry in our work!

We want the thickness of each ring to be a value between 0deg to 180deg — since we’re only actually making half of a circle — so we will divide 180deg by the number of layers, 16, which comes out to 11.25deg. Using the sin() trigonometric function (which is equivalent to the opposite and hypotenuse sides of a right angle), we get this expression for the layer’s --size:

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

So, whatever --i is in the HTML, it acts as a multiplier for calculating the layer’s border thickness. We have been declaring the layer’s border like this:

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

Now we can replace the hard-coded 16px value with --size calculation:

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

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

But! As you may have noticed, we aren’t changing the layer’s size when we change its border width. As a result, the round profile only appears on the layer’s inner side. The key thing here is understanding that setting the --size with the inset property which means it does not affect the element’s box-sizing. The result is a 3D ring for sure, but most of the shading is buried.

⚠️ Auto-playing media

We can bring the shading out by calculating a new inset for each layer. That’s kind of what I did in the 2020 version, but I think I’ve found an easier way: add an outline with the same border values to complete the arc on the outer side of the ring.

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

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

We have a more natural-looking ring now that we’ve established an outline:

Animating the rings

I had to animate the ring in that last demo to compare the ring’s shading before and after. We’ll use that same animation in the final demo, so let’s break down how I did that before we add the other four rings to the HTML

I’m not trying to do anything fancy; I’m just setting the rotation on the y-axis from -45deg to 45deg (the translateZ value remains constant).

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

As for the animation property, I’ve given named it ring , and a hard-coded (at least for now) a duration of 3s, that loops infinitely. Setting the animation’s timing function with ease-in-out and alternate, respectively, gives us a smooth back-and-forth motion.

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

That’s how the animation works!

Adding more rings

Now we can add the remaining four rings to the HTML. Remember, we have five rings total and each ring contains 16 layers. It could look as simple as this:

There’s something elegant about the simplicity of this markup. And we could use the CSS nth-child() pseudo-selector to select them individually. I like being a bit more declarative than that and am going to give each .ring and additional class we can use to explicitly select a given ring.

Our task now is to adjust each ring individually. Right now, everything looks like the first ring we made together. We’ll use the unique classes we just set in the HTML to give them their own color, position, and animation duration.

The good news? We’ve been using custom properties this entire time! All we have to do is update the values in each ring’s unique class.

.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; }
}

If you’re wondering where those --ringColor values came from, I based them on the International Olympic Committee’s documented colors. Each --duration is slightly offset from one another to stagger the movement between rings, and the rings are --translate‘d 120px apart and then staggered vertically by alternating their position 40px and -40px.

Let’s apply the translation stuff to the .ring elements:

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

Earlier, we set the animation’s duration to a hard-coded three seconds:

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

This is the time to replace that with a custom property that calculates the duration for each ring separately.

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

Whoa, whoa! What’s the -10s value doing in there? Even though each ring layer is set to animate for a different duration, the starting angle of the animations is all the same. Adding a constant negative delay on changing durations will make sure that each ring’s animation starts at a different angle.

Now we have something that is almost finished:

Some final touches

We’re at the final stretch! The animation looks pretty great as-is, but I want to add two more things. The first one is a small-10deg “tilt” on the x-axis of the parent .rings container. This will make it look like we’re viewing things from a higher perspective.

.rings {
  rotate: x -10deg;
}

The second finishing touch has to do with shadows. We can really punctuate the 3D depth of our work and all it takes is selecting the .ring element’s ::after pseudo-element and styling it like a shadow.

First, we’ll set the width of the pseudos’ border and outline to a constant (24px) while setting the color to a semi-transparent black (#0003). Then we’ll translate them so they appear to be further away. We’ll also inset them so they line up with the actual rings. Basically, we’re shifting the pseudo-elements around relative to the actual element.

.ring {
  /* etc. */

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

The pseudos don’t look very shadow-y at the moment. But they will if we blur() them a bit:

.ring {
  /* etc. */

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

The shadows are also pretty box-y. Let’s make sure they’re round like the rings:

.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%;
  }
}

Oh, and we ought to set the same animation on the pseudo so that the shadows move in harmony with the rings:

.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;
  }
}

Final demo

Let’s stop and admire our completed work:

At the end of the day, I’m really happy with the 2024 version of the Olympic rings. The 2020 version got the job done and was probably the right approach for that time. But with all of the features we’re getting in modern CSS today, I had plenty of opportunities to improve the code so that it is not only more efficient but more reusable — for example, this could be used in another project and “themed” simply by updating the --ringColor custom property.

Ultimately, this exercise proved to me the power and flexibility of modern CSS. We took an existing idea with complexities and recreated it with simplicity and elegance.