CSS Olympische Ringe

Avatar of Amit Sheen
Amit Sheen am

DigitalOcean bietet Cloud-Produkte für jede Phase Ihrer Reise. Starten Sie mit 200 $ kostenlosem Guthaben!

Es ist ein paar Jahre her, während der Olympischen Spiele 2020 in Tokio, als ich eine Demo von animierten 3D-Olympia-Ringen erstellte. Ich mag sie, sie sieht toll aus, und ich liebe den Effekt, wie sich die Ringe überkreuzen.

Aber der Code selbst ist etwas in die Jahre gekommen. Ich habe ihn in SCSS geschrieben, und das ziemlich krumm. Ich weiß, dass es besser geht, zumindest nach modernen Standards.

Deshalb habe ich beschlossen, die Demo zu Ehren der diesjährigen Olympischen Spiele noch einmal von Grund auf neu zu bauen. Diesmal schreibe ich reines (Vanilla) CSS und nutze moderne Funktionen wie trigonometrische Funktionen für weniger "magische Zahlen" und die relative Farbsyntax für ein besseres Farbmanagement. Der Clou dabei: Die neue Demo ist letztlich effizienter und hat weniger Codezeilen als die alte SCSS-Version von 2020!

Schauen Sie sich den CSS-Tab in der ersten Demo noch einmal an, denn wir werden mit dem Ansatz, den wir gemeinsam verfolgen, bei etwas völlig anderem – und besserem – landen. Also, fangen wir an!

Das Markup

Wir werden Ebenen (Layers) verwenden, um den 3D-Effekt zu erzielen. Diese Ebenen werden nacheinander (auf der z-Achse) positioniert, um die Tiefe des 3D-Objekts zu erhalten, das in unserem Fall ein Ring ist. Die Kombination aus Form, Größe und Farbe jeder Ebene – plus die Art und Weise, wie sie von Ebene zu Ebene variieren – erzeugt das vollständige 3D-Objekt.

In diesem Fall verwende ich 16 Ebenen, wobei jede Ebene einen anderen Farbton hat (mit den dunkleren Ebenen hinten gestapelt), um einen einfachen Lichteffekt zu erzielen. Die Größe und Dicke jeder Ebene wird genutzt, um eine runde, kreisförmige Form zu etablieren.

Was das HTML betrifft, benötigen wir fünf <div>-Elemente, eines für jeden Ring, wobei jedes <div> 16 Elemente enthält, die als Ebenen fungieren und die ich in <i>-Tags einschließe. Diese fünf Ringe platzieren wir in einem übergeordneten Container, um alles zusammenzuhalten. Wir geben dem Container die Klasse .rings und jedem Ring ganz kreativ die Klasse .ring.

Dies ist eine gekürzte Version des HTML, die zeigt, wie das Ganze zusammengefügt wird


<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>

Beachten Sie die benutzerdefinierte Eigenschaft --i, die ich im style-Attribut jedes <i>-Elements hinterlegt habe

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

Wir werden --i verwenden, um die Position, Größe und Farbe jeder Ebene zu berechnen. Deshalb habe ich ihre Werte als Ganzzahlen in aufsteigender Reihenfolge festgelegt – diese dienen als Multiplikatoren für die Anordnung und das Styling jeder einzelnen Ebene.

Profi-Tipp: Sie können das manuelle Schreiben des HTML für jede einzelne Ebene vermeiden, wenn Sie in einer IDE arbeiten, die Emmet unterstützt. Falls nicht, keine Sorge, CodePen kann das auch! Geben Sie folgendes in Ihren HTML-Editor ein und drücken Sie die Tab-Taste, um es in 16 Ebenen zu erweitern: i*16[style="--i: $;"]

Das (Vanilla) CSS

Beginnen wir mit dem übergeordneten .rings-Container, der vorerst nur eine relative Positionierung erhält. Ohne relative Positionierung würden die Ringe aus dem Dokumentfluss entfernt und irgendwo außerhalb der Seite landen, wenn wir sie absolut positionieren.

.rings {
  position: relative;
}

.ring {
  position: absolute;
}

Das Gleiche machen wir mit den <i>-Elementen, verwenden aber CSS-Nesting, um den Code kompakt zu halten. Wir fügen direkt border-radius hinzu, um die eckigen Kanten zu perfekten Kreisen abzurunden.

.rings {
  position: relative;
}

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

Das letzte grundlegende Styling, das wir anwenden, ist eine benutzerdefinierte Eigenschaft für die --ringColor. Dies macht das Einfärben der Ringe recht einfach, da wir sie einmal definieren und dann Ebene für Ebene überschreiben können. Wir deklarieren --ringColor auf der border-Eigenschaft, da wir nur die Außenkanten jeder Ebene einfärben wollen, anstatt sie komplett mit background-color zu füllen.

.rings {
  position: relative;
}

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

Ist Ihnen aufgefallen, dass ich dort noch etwas hineingeschmuggelt habe? Richtig, die Eigenschaft inset ist ebenfalls vorhanden und auf einen negativen Wert von 100px gesetzt. Das sieht vielleicht etwas merkwürdig aus, also lassen Sie uns zuerst darüber sprechen, während wir mit dem Styling fortfahren.

Negatives Insetting

Ein negativer Wert für die Eigenschaft inset bedeutet, dass die Position der Ebene außerhalb des .ring-Elements liegt. Man könnte es sich also eher als "outset" vorstellen. In unserem Fall hat der .ring keine Größe, da es keinen Inhalt oder CSS-Eigenschaften gibt, die ihm Dimensionen verleihen. Das bedeutet, dass das inset (oder vielmehr "outset") der Ebene 100px in jede Richtung beträgt, was zu einem .ring von 200×200 Pixeln führt.

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.

Schauen wir uns an, was wir bisher haben

Positionierung für die Tiefe

Wir nutzen die Ebenen, um den Eindruck von Tiefe zu erzeugen. Das erreichen wir, indem wir jede der 16 Ebenen entlang der z-Achse positionieren, was die Elemente von vorne nach hinten stapelt. Wir platzieren sie in einem Abstand von lediglich 2px – das ist der gesamte Platz, den wir benötigen, um eine leichte visuelle Trennung zwischen den Ebenen zu schaffen und die gewünschte Tiefe zu erhalten.

Erinnern Sie sich an die benutzerdefinierte Eigenschaft --i, die wir im HTML verwendet haben?

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

Auch hier sind dies Multiplikatoren, die uns helfen, jede Ebene entlang der z-Achse zu verschieben (translate). Erstellen wir eine neue benutzerdefinierte Eigenschaft, die die Gleichung definiert, damit wir sie auf jede Ebene anwenden können

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

Worauf wenden wir sie an? Wir können die CSS-Eigenschaft transform verwenden. Auf diese Weise können wir die Ebenen vertikal drehen (d. h. rotateY()) und sie gleichzeitig entlang der z-Achse verschieben.

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

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

Farbe für die Schattierung

Für die Farbschattierung dunkeln wir die Ebenen entsprechend ihrer Position ab, sodass sie dunkler werden, je weiter wir uns von der Vorderseite der z-Achse nach hinten bewegen. Es gibt verschiedene Wege, dies zu tun. Einer ist das Hinzufügen einer weiteren schwarzen Ebene mit abnehmender Deckkraft. Ein anderer ist das Ändern des Kanals für die Helligkeit ("lightness") in einer hsl()-Farbfunktion, wobei der Wert vorne heller und nach hinten schrittweise dunkler wird. Eine dritte Option ist das Spiel mit der Opazität der Ebene, was aber unübersichtlich werden kann.

Trotz dieser drei Ansätze halte ich die moderne CSS-relative-Farbsyntax für den besten Weg. Wir haben bereits eine Standard-Eigenschaft --ringColor definiert. Wir können sie durch die relative Farbsyntax schicken, um sie für jede <i>-Ebene in andere Farben zu manipulieren.

Zuerst benötigen wir eine neue benutzerdefinierte Eigenschaft, mit der wir einen Helligkeitswert berechnen können

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

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

Wir verwenden das mit calc() berechnete Ergebnis in einer weiteren benutzerdefinierten Eigenschaft, die unsere Standard---ringColor durch die relative Farbsyntax schickt, wobei die Eigenschaft --light hilft, die Helligkeit der resultierenden Farbe zu modifizieren.

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

Das ist eine ordentliche Gleichung! Aber sie sieht nur deshalb so komplex aus, weil die relative Farbsyntax Argumente für jeden Farbkanal (RGB) benötigt und wir jeden einzelnen berechnen.

rgb(from origin-color channelR channelG channelB)

Was die Berechnungen betrifft, multiplizieren wir jeden RGB-Kanal mit der --light-Eigenschaft, die eine Zahl zwischen 0 und 1 ist, geteilt durch die Anzahl der Ebenen, 16.

Zeit für einen weiteren Blick auf unseren Zwischenstand

Die Form erstellen

Um die kreisförmige Ringform zu erhalten, legen wir die Größe (d. h. die Dicke) der Ebene mit der Eigenschaft border fest. Hier können wir anfangen, Trigonometrie in unserer Arbeit einzusetzen!

Wir möchten, dass die Dicke jedes Rings einem Wert zwischen 0deg und 180deg entspricht – da wir eigentlich nur einen halben Kreis erstellen –, also teilen wir 180deg durch die Anzahl der Ebenen, 16, was 11.25deg ergibt. Mit der trigonometrischen Funktion sin() erhalten wir diesen Ausdruck für die --size der Ebene

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

Was auch immer --i im HTML ist, es fungiert als Multiplikator für die Berechnung der border-Dicke der Ebene. Bisher haben wir den Rahmen der Ebene so deklariert

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

Jetzt können wir den fest einprogrammierten Wert von 16px durch die --size-Berechnung ersetzen

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

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

Aber! Wie Sie vielleicht bemerkt haben, ändern wir nicht die Größe der Ebene, wenn wir ihre border-Breite ändern. Infolgedessen erscheint das runde Profil nur auf der Innenseite der Ebene. Der entscheidende Punkt hier ist das Verständnis, dass das Festlegen der --size mit der inset-Eigenschaft bedeutet, dass es das box-sizing des Elements nicht beeinflusst. Das Ergebnis ist zwar ein 3D-Ring, aber der Großteil der Schattierung ist verborgen.

⚠️ Medien werden automatisch abgespielt

Wir können die Schattierung hervorheben, indem wir für jede Ebene ein neues inset berechnen. Das habe ich in der Version von 2020 so gemacht, aber ich glaube, ich habe einen einfacheren Weg gefunden: das Hinzufügen einer outline mit denselben border-Werten, um den Bogen an der Außenseite des Rings zu vervollständigen.

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

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

Wir haben jetzt einen natürlicher aussehenden Ring, nachdem wir eine outline hinzugefügt haben

Die Ringe animieren

Ich musste den Ring in der letzten Demo animieren, um die Schattierung des Rings vor und nach der Änderung zu vergleichen. Wir werden dieselbe Animation in der finalen Demo verwenden, also lassen Sie uns aufschlüsseln, wie ich das gemacht habe, bevor wir die anderen vier Ringe zum HTML hinzufügen.

Ich versuche nichts allzu Kompliziertes; ich setze einfach die Rotation auf der y-Achse von -45deg auf 45deg (der translateZ-Wert bleibt konstant).

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

Was die Eigenschaft animation betrifft, habe ich sie ring genannt und eine (vorerst) fest definierte Dauer von 3s festgelegt, die unendlich oft wiederholt wird. Die Zeitfunktion der Animation mit ease-in-out und alternate sorgt für eine geschmeidige Hin- und Herbewegung.

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

So funktioniert die Animation!

Weitere Ringe hinzufügen

Jetzt können wir die restlichen vier Ringe zum HTML hinzufügen. Denken Sie daran, dass wir insgesamt fünf Ringe haben und jeder Ring 16 <i>-Ebenen enthält. Das könnte so einfach aussehen

<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>

Die Einfachheit dieses Markups hat etwas Elegantes. Wir könnten den CSS-Pseudoselektor nth-child() verwenden, um sie einzeln auszuwählen. Ich mag es jedoch etwas deklarativer und werde jedem .ring eine zusätzliche Klasse geben, mit der wir einen bestimmten Ring explizit auswählen können.

<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>

Unsere Aufgabe ist es nun, jeden Ring einzeln anzupassen. Im Moment sieht alles aus wie der erste Ring, den wir gemeinsam erstellt haben. Wir werden die eindeutigen Klassen, die wir gerade im HTML festgelegt haben, verwenden, um ihnen ihre eigene Farbe, Position und Animationsdauer zu geben.

Die gute Nachricht? Wir haben die ganze Zeit benutzerdefinierte Eigenschaften verwendet! Alles, was wir tun müssen, ist, die Werte in der jeweiligen Klasse jedes Rings zu aktualisieren.

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

Falls Sie sich fragen, woher diese --ringColor-Werte kommen: Ich habe sie an die dokumentierten Farben des Internationalen Olympischen Komitees angelehnt. Jede --duration ist leicht versetzt, um die Bewegung zwischen den Ringen zu staffeln, und die Ringe sind um 120px voneinander translate-iert und dann vertikal versetzt, indem ihre Position abwechselnd auf 40px und -40px gesetzt wird.

Wenden wir die Translation auf die .ring-Elemente an

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

Zuvor hatten wir die Dauer der Animation auf fest definierte drei Sekunden eingestellt

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

Dies ist der Zeitpunkt, um das durch eine benutzerdefinierte Eigenschaft zu ersetzen, die die Dauer für jeden Ring separat berechnet.

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

Halt, halt! Was macht der Wert -10s da drin? Obwohl jede Ringebene auf eine unterschiedliche Animationsdauer eingestellt ist, ist der Startwinkel der Animationen bei allen gleich. Das Hinzufügen einer konstanten negativen Verzögerung (Delay) bei sich ändernden Zeiträumen stellt sicher, dass die Animation jedes Rings in einem anderen Winkel beginnt.

Jetzt haben wir etwas, das fast fertig ist

Letzte Schliffe

Wir befinden uns auf der Zielgeraden! Die Animation sieht so schon ziemlich gut aus, aber ich möchte noch zwei Dinge hinzufügen. Das erste ist eine kleine Neigung von -10deg auf der x-Achse des übergeordneten .rings-Containers. Dadurch sieht es so aus, als würden wir die Dinge aus einer höheren Perspektive betrachten.

.rings {
  rotate: x -10deg;
}

Der zweite letzte Schliff hat mit Schatten zu tun. Wir können die 3D-Tiefe unserer Arbeit wirklich betonen, und alles, was dazu nötig ist, ist das Auswählen des ::after-Pseudoelements des .ring-Elements und das Styling als Schatten.

Zuerst legen wir die Breite der Rahmen und Outlines der Pseudoelemente auf einen konstanten Wert (24px) fest, während wir die Farbe auf ein halbtransparentes Schwarz (#0003) setzen. Dann werden wir sie so translate-ieren, dass sie weiter entfernt erscheinen. Außerdem setzen wir ein inset, damit sie mit den tatsächlichen Ringen übereinstimmen. Im Grunde verschieben wir die Pseudoelemente relativ zum eigentlichen Element.

.ring {
  /* etc. */

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

Die Pseudos sehen im Moment noch nicht sehr schattenhaft aus. Aber das werden sie, wenn wir sie ein wenig blur()-en (weichzeichnen)

.ring {
  /* etc. */

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

Die Schatten sind auch ziemlich eckig. Sorgen wir dafür, dass sie rund wie die Ringe sind

.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, und wir sollten die gleiche Animation auf das Pseudoelement anwenden, damit sich die Schatten harmonisch mit den Ringen bewegen

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

Finale Demo

Halten wir kurz inne und bewundern wir unser fertiges Werk

Letztendlich bin ich mit der 2024er-Version der Olympischen Ringe sehr zufrieden. Die Version von 2020 hat ihren Zweck erfüllt und war für die damalige Zeit wahrscheinlich der richtige Ansatz. Aber mit all den Funktionen, die wir heute im modernen CSS erhalten, hatte ich reichlich Gelegenheit, den Code zu verbessern, sodass er nicht nur effizienter, sondern auch besser wiederverwendbar ist – zum Beispiel könnte dies in einem anderen Projekt verwendet und einfach durch Aktualisieren der benutzerdefinierten Eigenschaft --ringColor angepasst werden.

Letztendlich hat mir diese Übung die Kraft und Flexibilität des modernen CSS bewiesen. Wir haben eine bestehende Idee mit Komplexitäten genommen und sie mit Einfachheit und Eleganz neu erschaffen.