A modern CSS rework of the 3D Olympic rings
Back in 2020, I built a demo of animated 3D Olympic rings using SCSS. It looked great, but the code wasn't exactly clean. Given that the Olympics roll around every four years, I figured now was a good time to rebuild the effect from scratch—this time using vanilla CSS with modern features like trigonometric functions and relative color syntax to cut down on magic numbers and improve color handling.
The result is more efficient than the older SCSS version, with fewer lines of code. Here’s how I built it, layer by layer.
Setting up the markup for depth
The 3D effect relies on stacking multiple layers along the z-axis. I used 16 layers per ring, each with different shades to simulate lighting and different sizes to form a circular shape. Darker layers sit at the back while lighter ones point toward the front.
The HTML structure consists of five <div> elements with a .ring class, each containing 16 <i> elements for the layers, all wrapped inside a parent .rings container. Here’s how the abbreviated markup looks:
<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>
Each <i> element takes a custom property through its style attribute:
<i style="--i: 1;"></i>
<i style="--i: 2;"></i>
<i style="--i: 3;"></i>
<!-- etc. -->
The --i property is an integer that acts as a multiplier to individually position, size, and color each layer relative to the others.
A quick tip: If your editor supports Emmet, you can generate the 16 layer elements with this snippet rather than writing them all out: i*16[style="--i: $;"].
Basic styling and the negative inset
We start with the parent container. The .rings element gets position: relative to anchor the absolutely positioned ring layers:
.rings {
position: relative;
}
.ring {
position: absolute;
}
The <i> layers also need absolute positioning. I use nested CSS to keep things compact, and add border-radius to create perfectly round shape:
.rings {
position: relative;
}
.ring {
position: absolute;
i {
position: absolute;
border-radius: 50%;
}
}
For the ring color, I define a --ringColor custom property on the border of each layer. This way, only the outer edge gets color instead of filling the entire element with a background:
.rings {
position: relative;
}
.ring {
position: absolute;
--ringColor: #0085c7;
i {
position: absolute;
inset: -100px;
border: 16px var(--ringColor) solid;
border-radius: 50%;
}
}
You’ll notice I also applied the inset property with a negative value of 100px. Setting a negative inset positions each layer outside the .ring element’s bounds. Because the .ring has no other content or dimensions, this effectively sizes it up to 200×200 pixels, with each layer sitting 100px away in every direction.

Let’s check in at this point:
Adding depth with transforms
To create the 3D stacking, the 16 layers are spaced 2px apart along the z-axis. That produces a slight visual gap between each layer, building the impression of a ring with its layers moving back and forth, none visible from the direct front view.
Here’s the --i variable at work again, used to multiply each layer’s translation:
<i style="--i: 1;"></i>
<i style="--i: 2;"></i>
<i style="--i: 3;"></i>
<!-- etc. -->
Each layer gets a custom property that defines the z-distance calculation:
i {
--translateZ: calc(var(--i) * 2px);
}
We apply it through a transform that rotates each layer along the y-axis while positioning it along the z-axis:
i {
--translateZ: calc(var(--i) * 2px);
transform: rotateY(-45deg) translateZ(var(--translateZ));
}
Using relative colors for shading
For the shading, the layers should darken as they move from front to back along the z-axis. Three approaches exist: layering a black overlay with decreasing opacity, adjusting the lightness channel in hsl(), or tweaking element opacity. But I chose to use the newer relative color syntax.
Starting from our declared --ringColor, I set up a calculation that produces a light value between 0 and 1 distributed across the 16 layers:
.ring {
--ringColor: #0085c7;
i {
--light: calc(var(--i) / 16);
border: 16px var(--ringColor) solid;
}
}
Then I put --ringColor through relative color syntax, using the calculated value to modify each RGB channel:
.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;
}
}
The equation looks long, but that’s only because the relative color syntax needs an argument for every RGB channel. The key operation is multiplying the base channel values by --light, which scales between 1 (no darkening) down to something smaller for the back layers.
rgb(from origin-color channelR channelG channelB)
Building the ring shape with trig
Each ring’s thickness comes from the layer’s border, and I used the sin() function to calculate the thickness. Since we’re actually building only half a circle, the angle runs from 0 to 180 degrees. So we divide 180 by the 16 layers, getting 11.25 degrees per step.
--size: calc(sin(var(--i) * 11.25deg) * 16px);
That expression replaces the fixed border thickness value we would have hard-coded:
i {
border: 16px var(--ringColor) solid;
)
But a problem: increasing the border width doesn’t change the element painting itself. The larger border only appears on the inner edge of the layer, leaving most of the shading hidden inside.
The fix came via adding an outline with matching border width, completing the profile on the element’s outer edges:
i {
--size: calc(sin(var(--i) * 11.25deg) * 16px);
border: var(--size) var(--layerColor) solid;
outline: var(--size) var(--layerColor) solid;
}
That creates a proper rounded ring effect. To visualize and verify it, I added a basic animation that compares before and after insetting:
Animating the ring
A simple rotation along the y-axis from -45deg to 45deg brings things to life. The translateZ() value stays constant for the duration:
@keyframes ring {
from { transform: rotateY(-45deg) translateZ(var(--translateZ, 0)); }
to { transform: rotateY(45deg) translateZ(var(--translateZ, 0)); }
}
The animation name is ring, with a fixed duration of 3s at this stage, looping infinitely. Using ease-in-out with alternate gives smooth back-and-forth motion:
i {
animation: ring 3s infinite ease-in-out alternate;
}
Styling all five rings
With one ring working, I added the rest to the structure. Each of the five rings carry 16 layers. I used both a shared .ring class and specific class names for each individual 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>
Each class sets its ring’s own color, position, and speed:
.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; }
}
The --ringColor values follow the International Olympic Committee’s documented colors. A slightly negative custom property --duration offsets each ring’s movement, and ring positions alternate in a 120px horizontal stagger with 40px vertical differences.
.ring { ... }
.ring {
transform: translate(var(--translate));
}
UPDATE: We need to replace the hard-coded 3s duration with a calculation that works for each ring:
i {
animation: ring var(--duration) -10s infinite ease-in-out alternate;
}
That dark magic behind the wide --duration gap? I added a constant negative delay to start each ring’s rotation at a different phase.
Last touches: Tilt and shadow
Adding a -10deg tilt on the x-axis of the parent container provides a perspective from above, making the depth more apparent.
.rings {
rotate: x -10deg;
}
For enriched 3D effect, I used the ::after pseudo-element of .ring as a shadow. The shadow consists of a semi-transparent black border and outline with fixed thickness, positioned slightly offset from the actual ring. Blurring those pseudo-elements provides soft shadow:
.ring {
/* etc. */
&::after {
content: '';
position: absolute;
inset: -100px;
border: 24px #0003 solid;
outline: 24px #0003 solid;
translate: 0 -100px -400px;
filter: blur(12px);
}
}
But a blurred box isn’t a suitable shadow. To give them a round shape matching their parent ring, add border-radius:
.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%;
}
}
The pseudo-elements get the same animation so they move in sync with their ring:
.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;
}
}
What’s different with modern CSS?
The 2020 version worked, but it leveraged SCSS for variables and nested logic, creating headaches. The modern rebuild uses fewer lines and is simpler to understand.
Custom properties have become more powerful—relative color syntax lets you adjust an RGB value by a numeric light factor without breaking the entire style. Sinusoidal trigonometry on the edge thickness replaces manual magic-number tuning.
This setup is actually reusable; you can easily drop it into a new project and theme it by just swapping the --ringColor value. That’s the core win this year over the 2020 take: it embodies the cleaner, more maintainable tools we now have in CSS.



