It was a few years ago during the 2020 Olympics in Tokyo 2020 that I made a demo of animated 3D Olympic rings. I like it, it looks great, and I love the effect of the rings crossing each other.
But the code itself is kind of old. I wrote it in SCSS, and crookedly at that. I know it could be better, at least by modern standards.
So, I decided to build the demo again from scratch in honor of this year’s Olympics. I’m writing vanilla CSS this time, leveraging modern features like trigonometric functions for fewer magic numbers and the relative color syntax for better color management. The kicker, turns out, is that the new demo winds up being more efficient with fewer lines of code than the old SCSS version I wrote in 2020!
Look at the CSS tab in that first demo again because we’ll wind up with something vastly different — and better — with the approach we’re going to use together. So, let’s begin!
The markup
We’ll use layers to create the 3D effect. These layers are positioned one after the other (on the z-axis) to get the depth of the 3D object which, in our case, is a ring. The combination of the shape, size, and color of each layer — plus the way they vary from layer to layer — is what creates the full 3D object.
In this case, I’m using 16 layers where each layer is a different shade (with the darker layers stacked at the back) to get a simple lighting effect, and using the size and thickness of each layer to establish a round, circular shape.
As far as HTML goes, we need five <div>
elements, one for each ring, where each <div>
contains 16 elements that act as the layers, which I’m wrapping in <i>
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:
<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>
Note the --i
custom property I’ve dropped on the style
attribute of each <i>
element:
<i style="--i: 1;"></i>
<i style="--i: 2;"></i>
<i style="--i: 3;"></i>
<!-- etc. -->
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 <i>
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?
<i style="--i: 1;"></i>
<i style="--i: 2;"></i>
<i style="--i: 3;"></i>
<!-- etc. -->
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 <i>
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 <i>
layers. It could look as simple as this:
<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>
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.
<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>
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.
CSS Olympic Rings