Absolute Wert, Vorzeichen, Runden und Modulo in CSS heute verwenden

Avatar of Ana Tudor
Ana Tudor am

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

Seit geraumer Zeit enthält die CSS-Spezifikation viele wirklich nützliche mathematische Funktionen, wie trigonometrische Funktionen (sin(), cos(), tan(), asin(), acos(), atan(), atan2()), Exponentialfunktionen (pow(), exp(), sqrt(), log(), hypot()), vorzeichenbezogene Funktionen (abs(), sign()) und gestufte Wertfunktionen (round(), mod(), rem()).

Diese sind jedoch noch in keinem Browser implementiert. Daher zeigt dieser Artikel, wie wir mit bereits vorhandenen CSS-Funktionen die Werte berechnen können, die abs(), sign(), round() und mod() zurückgeben sollten. Und dann werden wir sehen, welche coolen Dinge wir damit heute erstellen können.

Screenshot collage - a 2x2 grid. The first one shows the items of a full-screen navigation sliding down with a delay that's proportional to the distance to the selected one. The second one shows a cube with each face made of neon tiles; these tiles shrink and go inwards, into the cube, with a delay that depends on the distance from the midlines of the top face. The third one is a time progress with a tooltip showing the elapsed time in a mm::ss format. The fourth one is a 3D rotating musical toy with wooden and metallic stars and a wooden crescent moon hanging from the top.
Einige der Dinge, die diese Funktionen ermöglichen.

Beachten Sie, dass keine dieser Techniken für Browser aus der Zeit gedacht war, als Dinosaurier das Internet durchstreiften. Einige von ihnen hängen sogar davon ab, dass der Browser die Möglichkeit unterstützt, benutzerdefinierte Eigenschaften zu registrieren (mit @property), was bedeutet, dass sie derzeit auf Chromium beschränkt sind.

Die berechneten Äquivalente

--abs

Dies können wir durch die Verwendung der neuen CSS-Funktion max() erreichen, die bereits in den aktuellen Versionen aller gängigen Browser implementiert ist.

Nehmen wir an, wir haben eine benutzerdefinierte Eigenschaft, --a. Wir wissen nicht, ob diese positiv oder negativ ist, und wollen ihren absoluten Wert erhalten. Dies tun wir, indem wir das Maximum zwischen diesem Wert und seinem additiven Inversen wählen.

--abs: max(var(--a), -1*var(--a));

Wenn --a positiv ist, bedeutet dies, dass es größer als Null ist, und die Multiplikation mit -1 ergibt eine negative Zahl, die immer kleiner als Null ist. Diese ist wiederum immer kleiner als das positive --a, sodass das von max() zurückgegebene Ergebnis gleich var(--a) ist.

Wenn --a negativ ist, bedeutet dies, dass es kleiner als Null ist, und die Multiplikation mit -1 ergibt eine positive Zahl, die immer größer als Null ist, welche wiederum immer größer als das negative --a ist. Das von max() zurückgegebene Ergebnis ist also gleich -1*var(--a).

--sign

Dies können wir durch die Verwendung des vorherigen Abschnitts erreichen, da das Vorzeichen einer Zahl diese Zahl dividiert durch ihren absoluten Wert ist.

--abs: max(var(--a), -1*var(--a));
--sign: calc(var(--a)/var(--abs));

Eine sehr wichtige Sache hierbei ist, dass dies *nur funktioniert, wenn --a einheitslos ist*, da wir in calc() nicht durch eine Zahl mit einer Einheit dividieren können.

Auch wenn --a 0 ist, funktioniert diese Lösung nur, wenn wir --sign (dies wird derzeit nur in Chromium-Browsern unterstützt) mit einem initial-value von 0 registrieren.

@property --sign {
  syntax: '<integer>';
  initial-value: 0;
  inherits: false /* or true depending on context */
}

Das liegt daran, dass --a, wenn es 0 ist, auch --abs zu 0 berechnet – und die Division durch 0 ist in CSS calc() ungültig – daher müssen wir sicherstellen, dass --sign in dieser Situation auf 0 zurückgesetzt wird. Beachten Sie, dass dies nicht geschieht, wenn wir es einfach in CSS vor der Zuweisung des calc()-Wertes auf 0 setzen und es nicht registrieren.

--abs: max(var(--a), -1*var(--a));
--sign: 0; /* doesn't help */
--sign: calc(var(--a)/var(--abs));

In der Praxis habe ich für ganze Zahlen auch oft die folgende Version verwendet.

--sign: clamp(-1, var(--a), 1);

Hier verwenden wir eine clamp() Funktion. Diese nimmt drei Argumente: einen minimal zulässigen Wert -1, einen bevorzugten Wert var(--a) und einen maximal zulässigen Wert 1. Der zurückgegebene Wert ist der bevorzugte Wert, solange er zwischen den unteren und oberen Grenzen liegt, und andernfalls das überschrittene Limit.

Wenn --a eine negative ganze Zahl ist, bedeutet dies, dass sie kleiner oder gleich -1 ist, der unteren Grenze (oder dem minimal zulässigen Wert) unserer clamp()-Funktion, sodass der zurückgegebene Wert -1 ist. Wenn es eine positive ganze Zahl ist, bedeutet dies, dass sie größer oder gleich 1 ist, der oberen Grenze (oder dem maximal zulässigen Wert) der clamp()-Funktion, sodass der zurückgegebene Wert 1 ist. Und schließlich, wenn --a 0 ist, liegt es zwischen den unteren und oberen Grenzen, sodass die Funktion seinen Wert (in diesem Fall 0) zurückgibt.

Diese Methode hat den Vorteil, einfacher zu sein und keine Houdini-Unterstützung zu erfordern. Beachten Sie jedoch, dass sie nur für einheitslose Werte funktioniert (das Vergleichen eines Längen- oder Winkelwerts mit ganzen Zahlen wie ±1 ist wie Äpfel mit Birnen zu vergleichen – es funktioniert nicht!), die entweder genau 0 sind oder absolut mindestens 1 betragen. Bei einem sub-unitären Wert wie -.05 schlägt unsere obige Methode fehl, da der zurückgegebene Wert -.05 ist, nicht -1!

Mein erster Gedanke war, dass wir diese Technik auf sub-unitäre Werte ausdehnen können, indem wir einen Grenzwert einführen, der kleiner ist als der kleinste Nicht-Null-Wert, den --a annehmen kann. Nehmen wir zum Beispiel an, unser Grenzwert ist .000001 – dies würde es uns ermöglichen, für -.05 korrekt -1 als Vorzeichen und für .0001 1 als Vorzeichen zu erhalten!

--lim: .000001;
--sign: clamp(-1*var(--lim), var(--a), var(--lim));

Temani Afif schlug eine einfachere Version vor, bei der --a mit einer sehr großen Zahl multipliziert wird, um einen super-unitären Wert zu erzeugen.

--sign: clamp(-1, var(--a)*10000, 1);

Ich habe mich schließlich dafür entschieden, --a durch den Grenzwert zu teilen, da es intuitiver ist zu sehen, welchen minimalen Nicht-Null-Wert er nicht unterschreiten wird.

--lim: .000001;
--sign: clamp(-1, var(--a)/var(--lim), 1);

--round (sowie --ceil und --floor)

Hier habe ich eine Weile festgesteckt, bis ich einen cleveren Vorschlag für ein ähnliches Problem von Christian Schaefer erhielt. Genau wie beim Vorzeichen funktioniert dies nur bei einheitslosen Werten und erfordert die Registrierung der Variablen --round als <integer>, damit wir das Runden auf jeden Wert erzwingen, den wir ihr zuweisen.

@property --round {
  syntax: '<integer>';
  initial-value: 0;
  inherits: false /* or true depending on context */
}

.my-elem { --round: var(--a); }

Erweiternd können wir --floor und --ceil erhalten, indem wir .5 subtrahieren oder addieren.

@property --floor {
  syntax: '<integer>';
  initial-value: 0;
  inherits: false /* or true depending on context */
}

@property --ceil {
  syntax: '<integer>';
  initial-value: 0;
  inherits: false /* or true depending on context */
}

.my-elem {
  --floor: calc(var(--a) - .5);
  --ceil: calc(var(--a) + .5)
}

--mod

Dies baut auf der --floor-Technik auf, um einen ganzzahligen Quotienten zu erhalten, der es uns dann ermöglicht, den Modulowert zu ermitteln. Das bedeutet, dass beide unsere Werte einheitslos sein müssen.

@property --floor {
  syntax: '<integer>';
  initial-value: 0;
  inherits: false /* or true depending on context */
}

.my-elem {
  --floor: calc(var(--a)/var(--b) - .5);
  --mod: calc(var(--a) - var(--b)*var(--floor))
}

Anwendungsfälle

Was können wir mit dieser Technik tun? Betrachten wir drei Anwendungsfälle.

Mühelose Symmetrie in gestaffelten Animationen (und nicht nur!)

Während der absolute Wert uns helfen kann, symmetrische Ergebnisse für viele Eigenschaften zu erzielen, sind animation-delay und transition-delay die, bei denen ich ihn am häufigsten verwendet habe. Sehen wir uns also einige Beispiele dafür an!

Wir platzieren --n Elemente innerhalb eines Containers, wobei jedes dieser Elemente einen Index --i hat. Sowohl --n als auch --i sind Variablen, die wir über style-Attribute an das CSS übergeben.

- let n = 16;

.wrap(style=`--n: ${n}`)
  - for(let i = 0; i < n; i++)
    .item(style=`--i: ${i}`)

Dies ergibt den folgenden kompilierten HTML-Code.

<div class='wrap' style='--n: 16'>
  <div class='item' style='--i: 0'></div>
  <div class='item' style='--i: 1'></div>
  <!-- more such items -->
</div>

Wir legen einige Stile fest, sodass die Elemente in einer Reihe angeordnet sind und quadratisch mit einer nicht-null Kantenlänge.

$r: 2.5vw;

.wrap {
  display: flex;
  justify-content: space-evenly;
}

.item { padding: $r; }
Screenshot showing the items lined in a row and DevTools with the HTML structure and the styles applied.
Das bisherige Ergebnis.

Nun fügen wir zwei Sätze von Keyframes hinzu, um eine skalierende transform und einen box-shadow zu animieren. Der erste Satz von Keyframes, grow, lässt unsere Elemente von nichts bei 0% auf volle Größe bei 50% skalieren, danach bleiben sie bis zum Ende in voller Größe. Der zweite Satz von Keyframes, melt, zeigt uns die Elemente mit inset-Boxschatten, die sie bis zur Hälfte der animation (bei 50%) vollständig bedecken. Das ist auch der Zeitpunkt, an dem die Elemente nach dem Wachsen aus dem Nichts ihre volle Größe erreichen. Dann schrumpft der Streuradius dieser inset-Schatten, bis er bei 100% nichts mehr ist.

$r: 2.5vw;

.item {
  padding: $r;
  animation: a $t infinite;
  animation-name: grow, melt;
}

@keyframes grow {
  0% { transform: scale(0); }
  50%, 100% { transform: none; }
}

@keyframes melt {
  0%, 50% { box-shadow: inset 0 0 0 $r; }
  100% { box-shadow: inset 0 0; }
}
Animated gif. Shows 16 black square tiles in a row growing from nothing to full size, then melting from the inside until they disappear. The cycle then repeats. In this case, all tiles animate at the same time.
Die Basis-Animation (Live-Demo).

Nun kommt der interessante Teil! Wir berechnen die Mitte zwischen dem Index des ersten und dem des letzten Elements. Dies ist das arithmetische Mittel der beiden (da unsere Indizes nullbasiert sind, sind das erste und das letzte 0 bzw. n - 1).

--m: calc(.5*(var(--n) - 1));

Wir ermitteln den absoluten Wert, --abs, der Differenz zwischen dieser Mitte, --m, und dem Elementindex, --i, und verwenden ihn dann zur Berechnung des animation-delay.

--abs: max(var(--m) - var(--i), var(--i) - var(--m));
animation: a $t calc(var(--abs)/var(--m)*#{$t}) infinite backwards;
animation-name: grow, melt;

Der absolute Wert, --abs, der Differenz zwischen der Mitte, --m, und dem Elementindex, --i, kann so klein wie 0 sein (für das mittlere Element, wenn --n ungerade ist) und so groß wie --m (für die Endelemente). Das bedeutet, dass die Division durch --m immer einen Wert im Intervall [0, 1] ergibt, den wir dann mit der Animationsdauer $t multiplizieren, um sicherzustellen, dass jedes Element eine Verzögerung zwischen 0s und der animation-duration hat.

Beachten Sie, dass wir auch animation-fill-mode auf backwards gesetzt haben. Da die meisten Elemente die Animationen später beginnen, weist dies den Browser an, sie bis dahin mit den Stilen der 0% Keyframes beizubehalten.

In diesem speziellen Fall würden wir auch ohne dies keinen Unterschied sehen, da die Elemente zwar in voller Größe wären (nicht auf Null skaliert wie im 0% Keyframe der grow-Animation), aber auch keinen box-shadow hätten, bis sie mit der Animation beginnen. In vielen anderen Fällen macht es jedoch einen Unterschied und wir sollten es nicht vergessen.

Eine weitere Möglichkeit (eine, die das Setzen von animation-fill-mode nicht erfordert) wäre, sicherzustellen, dass die animation-delay immer kleiner oder höchstens gleich 0 ist, indem wir eine volle animation-duration davon abziehen.

--abs: max(var(--m) - var(--i), var(--i) - var(--m));
animation: a $t calc((var(--abs)/var(--m) - 1)*#{$t}) infinite;
animation-name: grow, melt;

Beide Optionen sind gültig, und welche Sie wählen, hängt davon ab, was am Anfang passieren soll. Ich tendiere im Allgemeinen zu negativen Verzögerungen, da sie sinnvoller sind, wenn man die sich wiederholende Animation aufnimmt, um ein GIF wie das unten zu erstellen, das veranschaulicht, wie die Werte von animation-delay symmetrisch bezüglich der Mitte sind.

Animated gif. Shows 16 black square tiles in a row, each of them growing from nothing to full size, then melting from the inside until they disappear, with the cycle then repeating. Only now, they don't all animate at the same time. The closer they are to the middle, the sooner they start their animation, those at the very ends of the row being one full cycle behind those in the very middle.
Die gestaffelte Schleifenanimation.

Für einen visuellen Vergleich zwischen den beiden Optionen können Sie die folgende Demo erneut ausführen, um zu sehen, was am Anfang passiert.

Ein ausgefalleneres Beispiel wäre das Folgende.

Navigationslinks schieben sich hoch und dann wieder herunter, mit einer Verzögerung, die proportional zur Entfernung vom ausgewählten Link ist.

Hier hat jedes der --n Navigationslinks und die entsprechenden Rezeptartikel einen Index --idx. Wenn ein Navigationslink gehovert oder fokussiert wird, wird sein --idx-Wert gelesen und auf den aktuellen Index, --k, des body gesetzt. Wenn keines dieser Elemente gehovert oder fokussiert wird, wird --k auf einen Wert außerhalb des Intervalls [0, n) gesetzt (z.B. -1).

Der absolute Wert, --abs, der Differenz zwischen --k und dem Index eines Links, --idx, kann uns sagen, ob dies das aktuell ausgewählte (gehoverte oder fokussierte) Element ist. Wenn dieser absolute Wert 0 ist, dann ist unser Element das aktuell ausgewählte (d.h. --not-sel ist 0 und --sel ist 1). Wenn dieser absolute Wert größer als 0 ist, dann ist unser Element nicht das aktuell ausgewählte (d.h. --not-sel ist 1 und --sel ist 0).

Da sowohl --idx als auch --k ganze Zahlen sind, ist ihre Differenz ebenfalls eine ganze Zahl. Das bedeutet, dass der absolute Wert, --abs, dieser Differenz entweder 0 (wenn das Element ausgewählt ist) oder größer oder gleich 1 (wenn das Element nicht ausgewählt ist) ist.

Wenn wir all dies in Code umwandeln, erhalten wir Folgendes.

--abs: Max(var(--k) - var(--idx), var(--idx) - var(--k));
--not-sel: Min(1, var(--abs));
--sel: calc(1 - var(--not-sel));

Die Eigenschaften --sel und --not-sel (die immer ganze Zahlen sind und sich immer zu 1 addieren) bestimmen die Größe der Navigationslinks (die width im Breitbildszenario und die height im Schmalbildszenario), ob sie abgegraut sind oder nicht und ob ihr Textinhalt versteckt ist oder nicht. Dies wird hier nicht näher behandelt, da es außerhalb des Rahmens dieses Artikels liegt und ich es bereits in einem früheren Artikel ausführlich erklärt habe.

Relevant ist hier, dass beim Klicken auf einen Navigationslink dieser aus dem Blickfeld gleitet (nach oben im Breitbildfall, und nach links im Schmalbildfall), gefolgt von allen anderen um ihn herum, jeweils mit einer transition-delay, die davon abhängt, wie weit sie von dem geklickten Link entfernt sind (d.h. vom absoluten Wert, --abs, der Differenz zwischen ihrem Index, --idx, und dem Index des aktuell ausgewählten Elements, --k), wodurch der entsprechende Rezeptartikel angezeigt wird. Diese Werte von transition-delay sind symmetrisch bezüglich des aktuell ausgewählten Elements.

transition: transform 1s calc(var(--abs)*.05s);

Der eigentliche Übergang und die Verzögerung sind tatsächlich etwas komplexer, da mehr Eigenschaften als nur die transform animiert werden, und insbesondere bei transform gibt es eine zusätzliche Verzögerung beim Zurückkehren vom Rezept-article zu den Navigationslinks, da wir warten, bis das <article>-Element verschwunden ist, bevor wir die Links heruntergleiten lassen. Aber worauf wir uns konzentrieren, ist die Komponente der Verzögerung, die dazu führt, dass die Links, die näher am ausgewählten sind, aus dem Blickfeld gleiten, bevor die weiter entfernten. Und das wird wie oben berechnet, unter Verwendung der Variablen --abs.

Sie können mit der interaktiven Demo unten experimentieren.

Die Dinge werden in 2D noch interessanter, also machen wir nun aus unserer Reihe ein Gitter!

Wir beginnen damit, die Struktur ein wenig zu ändern, sodass wir 8 Spalten und 8 Zeilen haben (was bedeutet, dass wir insgesamt 8·8 = 64 Elemente im Gitter haben).

- let n = 8;
- let m = n*n;

style
  - for(let i = 0; i < n; i++)
    | .item:nth-child(#{n}n + #{i + 1}) { --i: #{i} }
    | .item:nth-child(n + #{n*i + 1}) { --j: #{i} }
.wrap(style=`--n: ${n}`)
  - for(let i = 0; i < m; i++)
    .item

Der obige Pug-Code wird in den folgenden HTML-Code kompiliert.

<style>
  .item:nth-child(8n + 1) { --i: 0 } /* items on 1st column */
  .item:nth-child(n + 1) { --j: 0 } /* items starting from 1st row */
  .item:nth-child(8n + 2) { --i: 1 } /* items on 2nd column */
  .item:nth-child(n + 9) { --j: 1 } /* items starting from 2nd row */
  /* 6 more such pairs */
</style>
<div class='wrap' style='--n: 8'>
  <div class='item'></div>
  <div class='item'></div>
  <!-- 62 more such items -->
</div>

Ähnlich wie im vorherigen Fall berechnen wir einen mittleren Index, --m, aber da wir von 1D zu 2D gewechselt haben, haben wir nun zwei Differenzen im absoluten Wert zu berechnen, eine für jede der beiden Dimensionen (eine für die Spalten, --abs-i, und eine für die Zeilen, --abs-j).

--m: calc(.5*(var(--n) - 1));
--abs-i: max(var(--m) - var(--i), var(--i) - var(--m));
--abs-j: max(var(--m) - var(--j), var(--j) - var(--m));

Wir verwenden genau dieselben beiden Sätze von @keyframes, aber die animation-delay ändert sich ein wenig, sodass sie von beiden, --abs-i und --abs-j, abhängt. Diese absoluten Werte können so klein wie 0 sein (für Kacheln genau in der Mitte der Spalten und Zeilen) und so groß wie --m (für Kacheln an den Enden der Spalten und Zeilen), was bedeutet, dass das Verhältnis zwischen beiden und --m immer im Intervall [0, 1] liegt. Das bedeutet, dass die Summe dieser beiden Verhältnisse immer im Intervall [0, 2] liegt. Wenn wir es auf das Intervall [0, 1] reduzieren wollen, müssen wir es durch 2 teilen (oder mit .5 multiplizieren, dasselbe).

animation-delay: calc(.5*(var(--abs-i)/var(--m) + var(--abs-j)/var(--m))*#{$t});

Dies ergibt uns Verzögerungen im Intervall [0s, $t]. Wir können den Nenner, var(--m), aus der Klammer nehmen, um die obige Formel etwas zu vereinfachen.

animation-delay: calc(.5*(var(--abs-i) + var(--abs-j))/var(--m)*#{$t});

Ähnlich wie im vorherigen Fall beginnen die Gitterelemente später mit der Animation, je weiter sie von der Mitte des Gitters entfernt sind. Wir sollten animation-fill-mode: backwards verwenden, um sicherzustellen, dass sie im Zustand verbleiben, der durch die 0% Keyframes angegeben ist, bis die Verzögerungszeit abgelaufen ist und sie mit der Animation beginnen.

Alternativ können wir eine Animationsdauer $t von allen Verzögerungen abziehen, um sicherzustellen, dass alle Gitterelemente ihre animation bereits beim Laden der Seite gestartet haben.

animation-delay: calc((.5*(var(--abs-i) + var(--abs-j))/var(--m) - 1)*#{$t});

Dies ergibt das folgende Ergebnis.

Animated gif. Shows an 8x8 grid of tiles, each of them growing from nothing to full size, then melting from the inside until they disappear, with the cycle then repeating. The smaller the sum of their distances to the middle is, the sooner they start their animation, those at the very corners of the grid being one full cycle behind those in the very middle.
Die gestaffelte 2D-Animation (Live-Demo).

Sehen wir uns nun ein paar weitere interessante Beispiele an. Wir werden nicht ins Detail gehen, wie sie funktionieren, da die Technik der symmetrischen Werte genauso funktioniert wie bei den vorherigen, und der Rest außerhalb des Rahmens dieses Artikels liegt. Es gibt jedoch in jeder der folgenden Beispiele einen Link zu einer CodePen-Demo, und die meisten dieser Pens enthalten auch eine Aufnahme, die zeigt, wie ich sie von Grund auf programmiert habe.

Im ersten Beispiel besteht jedes Gitterelement aus zwei Dreiecken, die sich an den gegenüberliegenden Enden der Diagonale, entlang derer sie sich treffen, zu nichts verkleinern und dann wieder auf volle Größe wachsen. Da es sich um eine alternierende animation handelt, lassen wir die Verzögerungen über zwei Iterationen (eine normale und eine umgekehrte) strecken, was bedeutet, dass wir die Summe der Verhältnisse nicht mehr halbieren und 2 subtrahieren, um sicherzustellen, dass jedes Element eine negative Verzögerung hat.

animation: s $t ease-in-out infinite alternate;
animation-delay: calc(((var(--abs-i) + var(--abs-j))/var(--m) - 2)*#{$t});
Gitterwelle: pulsierende Dreiecke (Live-Demo)

Im zweiten Beispiel hat jedes Gitterelement einen Gradienten in einem Winkel, der von 0deg bis 1turn animiert wird. Dies ist über Houdini möglich, wie in diesem Artikel über den Status der Animation von Farbverläufen mit CSS erklärt wird.

Feldwelle: Zellengradienten-Rotation (Live-Demo)

Das dritte Beispiel ist sehr ähnlich, nur dass der animierte Winkel von einem conic-gradient anstelle eines linearen verwendet wird und auch von der Farbe des ersten Stopps.

Regenbogen-Stundenwelle (Live-Demo)

Im vierten Beispiel enthält jede Gitterzelle sieben Regenbogenpunkte, die sich auf und ab bewegen. Die Verzögerung der Oszillation hat eine Komponente, die von den Zellindizes abhängt, auf exakt dieselbe Weise wie bei den vorherigen Gittern (das Einzige, was hier anders ist, ist, dass die Anzahl der Spalten von der Anzahl der Zeilen abweicht, daher müssen wir zwei mittlere Indizes berechnen, einen entlang jeder der beiden Dimensionen) und eine Komponente, die vom Punktindex, --idx, relativ zur Anzahl der Punkte pro Zelle, --n-dots, abhängt.

--k: calc(var(--idx)/var(--n-dots));
--mi: calc(.5*(var(--n-cols) - 1));
--abs-i: max(var(--mi) - var(--i), var(--i) - var(--mi));
--mj: calc(.5*(var(--n-rows) - 1));
--abs-j: max(var(--mj) - var(--j), var(--j) - var(--mj));
animation-delay: 
  calc((var(--abs-i)/var(--mi) + var(--abs-j)/var(--mj) + var(--k) - 3)*#{$t});
Regenbogen-Punktwelle: Punktoszillation (Live-Demo)

Im fünften Beispiel schrumpfen die Kacheln, aus denen die Würfelflächen bestehen, und bewegen sich nach innen. Die animation-delay für die obere Fläche wird genau wie in unserer ersten 2D-Demo berechnet.

Atme in mich: Neon-Wasserfall (Live-Demo und eine frühere Iteration)

Im sechsten Beispiel haben wir ein Gitter von Spalten, die sich auf und ab bewegen.

Spaltenwelle (Live-Demo)

Die animation-delay ist nicht die einzige Eigenschaft, die wir mit symmetrischen Werten einstellen können. Dies können wir auch mit den Dimensionen der Elemente tun. Im siebten Beispiel unten sind die Kacheln um ein halbes Dutzend Ringe verteilt, beginnend von der vertikalen (y) Achse, und werden mit einem Faktor skaliert, der davon abhängt, wie weit sie vom oberen Punkt der Ringe entfernt sind. Dies ist im Grunde der 1D-Fall mit der auf einem Kreis gekrümmten Achse.

Kreisförmige Gitter-Schmelze (Live-Demo)

Das achte Beispiel zeigt zehn Arme von Kugeln, die sich um eine große Kugel wickeln. Die Größe dieser Kugeln hängt davon ab, wie weit sie von den Polen entfernt sind, wobei die nächsten die kleinsten sind. Dies geschieht durch die Berechnung des mittleren Index, --m, für die Punkte auf einem Arm und des absoluten Wertes, --abs, der Differenz zwischen ihm und dem aktuellen Kugelnindex, --j, dann wird das Verhältnis zwischen diesem absoluten Wert und dem mittleren Index verwendet, um den Skalierungsfaktor, --f, zu erhalten, den wir dann bei der Einstellung des padding verwenden.

--m: calc(.5*(var(--n-dots) - 1));
--abs: max(var(--m) - var(--j), var(--j) - var(--m));
--f: calc(1.05 - var(--abs)/var(--m));
padding: calc(var(--f)*#{$r});
Reise ins Innere der Kugel (Live-Demo)

Unterschiedliche Stile für Elemente vor und nach einem bestimmten (ausgewählten oder mittleren) Element

Nehmen wir an, wir haben eine Reihe von Radio-Buttons und Labels, wobei die Labels einen als benutzerdefinierte Eigenschaft gesetzten Index haben, --i. Wir möchten, dass die Labels vor dem ausgewählten Element einen grünen Hintergrund haben, das Label des ausgewählten Elements einen blauen Hintergrund und die restlichen Labels einen grauen Hintergrund. Auf dem body setzen wir den Index der aktuell ausgewählten Option als eine weitere benutzerdefinierte Eigenschaft, --k.

- let n = 8;
- let k = Math.round((n - 1)*Math.random());

body(style=`--k: ${k}`)
  - for(let i = 0; i < n; i++)
    - let id = `r${i}`;
    input(type='radio' name='r' id=id checked=i===k)
    label(for=id style=`--i: ${i}`) Option ##{i}

Dies wird in den folgenden HTML-Code kompiliert.

<body style='--k: 1'>
  <input type='radio' name='r' id='r0'/>
  <label for='r0' style='--i: 0'>Option #0</label>
  <input type='radio' name='r' id='r1' checked='checked'/>
  <label for='r1' style='--i: 1'>Option #1</label>
  <input type='radio' name='r' id='r2'/>
  <label for='r2' style='--i: 2'>Option #2</label>
  <!-- more options -->
</body>

Wir legen einige Layout- und Aufhübschungsstile fest, einschließlich eines Gradienten-background auf den Labels, der drei vertikale Streifen erzeugt, die jeweils ein Drittel der background-size einnehmen (die derzeit nur die Standardgröße von 100%, die gesamte Elementbreite, hat).

$c: #6daa7e, #335f7c, #6a6d6b;

body {
  display: grid;
  grid-gap: .25em 0;
  grid-template-columns: repeat(2, max-content);
  align-items: center;
  font: 1.25em/ 1.5 ubuntu, trebuchet ms, sans-serif;
}

label {
  padding: 0 .25em;
  background: 
    linear-gradient(90deg, 
      nth($c, 1) 33.333%, 
      nth($c, 2) 0 66.667%, 
      nth($c, 3) 0);
  color: #fff;
  cursor: pointer;
}
Screenshot showing radio inputs and their labels on two grid columns. The labels have a vertical three stripe background with the first stripe being green, the second one blue and the last one grey.
Das bisherige Ergebnis.

Aus dem JavaScript aktualisieren wir den Wert von --k, wenn wir eine andere Option auswählen.

addEventListener('change', e => {
  let _t = e.target;
	
  document.body.style.setProperty('--k', +_t.id.replace('r', ''))
})

Nun kommt der interessante Teil! Für unsere label-Elemente berechnen wir das Vorzeichen, --sgn, der Differenz zwischen dem Labelindex, --i, und dem Index der aktuell ausgewählten Option, --k. Wir verwenden dann diesen --sgn-Wert, um die background-position zu berechnen, wenn die background-size auf 300% gesetzt ist – also das Dreifache der width des Labels, da wir drei mögliche Hintergründe haben können: einen für den Fall, dass das Label für eine Option vor der ausgewählten ist, einen zweiten für den Fall, dass das Label für die ausgewählte Option ist, und einen dritten für den Fall, dass das Label für eine Option nach der ausgewählten ist.

--sgn: clamp(-1, var(--i) - var(--k), 1);
background: 
  linear-gradient(90deg, 
      nth($c, 1) 33.333%, 
      nth($c, 2) 0 66.667%, 
      nth($c, 3) 0) 
    calc(50%*(1 + var(--sgn)))/ 300%

Wenn --i kleiner als --k ist (Fall eines label für eine Option vor der ausgewählten), dann ist --sgn -1 und die background-position berechnet sich zu 50%*(1 + -1) = 50%*0 = 0%, was bedeutet, dass wir nur den ersten vertikalen Streifen (den grünen) sehen.

Wenn --i gleich --k ist (Fall des label für die ausgewählte Option), dann ist --sgn 0 und die background-position berechnet sich zu 50%*(1 + 0) = 50%*1 = 50%, sodass wir nur den vertikalen Streifen in der Mitte (den blauen) sehen.

Wenn --i größer als --k ist (Fall eines label für eine Option nach der ausgewählten), dann ist --sgn 1 und die background-position berechnet sich zu 50%*(1 + 1) = 50%*2 = 100%, was bedeutet, dass wir nur den letzten vertikalen Streifen (den grauen) sehen.

Ein ästhetisch ansprechenderes Beispiel wäre die folgende Navigation, bei der sich der vertikale Balken auf der Seite befindet, die der ausgewählten Option am nächsten ist, und bei der ausgewählten Option sich über das gesamte Element erstreckt.

Dies verwendet eine Struktur, die der des vorherigen Demos ähnelt, mit Radio-Inputs und Labels für die Navigationselemente. Der sich bewegende „Hintergrund“ ist tatsächlich ein ::after-Pseudoelement, dessen Translationswert vom Vorzeichen, --sgn, abhängt. Der Text ist ein ::before-Pseudoelement, dessen Position sich in der Mitte des weißen Bereichs befinden soll, sodass sein Translationswert ebenfalls von --sgn abhängt.

/* relevant styles */
label {
  --sgn: clamp(-1, var(--k) - var(--i), 1);
  
  &::before {
    transform: translate(calc(var(--sgn)*-.5*#{$pad}))
  }
  &::after {
    transform: translate(calc(var(--sgn)*(100% - #{$pad})))
  }
}

Betrachten wir nun kurz einige weitere Demos, bei denen die Berechnung des Vorzeichens (und vielleicht auch des absoluten Wertes) nützlich ist.

Zuerst haben wir ein quadratisches Gitter von Zellen mit einem radial-gradient, dessen Radius von der vollständigen Bedeckung der Zelle bis zu nichts schrumpft. Diese animation hat eine Verzögerung, die wie im vorherigen Abschnitt erklärt berechnet wird. Neu ist hier, dass die Koordinaten des radial-gradient-Kreises davon abhängen, wo die Zelle relativ zur Mitte des Gitters positioniert ist – das heißt, von den Vorzeichen der Differenzen zwischen den Spalten---i und Zeilen---j-Indizes und dem mittleren Index, --m.

/* relevant CSS */
$t: 2s;

@property --p {
  syntax: '<length-percentage>';
  initial-value: -1px;
  inherits: false;
}

.cell {
  --m: calc(.5*(var(--n) - 1));
  --dif-i: calc(var(--m) - var(--i));
  --abs-i: max(var(--dif-i), -1*var(--dif-i));
  --sgn-i: clamp(-1, var(--dif-i)/.5, 1);
  --dif-j: calc(var(--m) - var(--j));
  --abs-j: max(var(--dif-j), -1*var(--dif-j));
  --sgn-j: clamp(-1, var(--dif-j)/.5, 1);
  background: 
    radial-gradient(circle
      at calc(50% + 50%*var(--sgn-i)) calc(50% + 50%*var(--sgn-j)), 
      currentcolor var(--p), transparent calc(var(--p) + 1px))
      nth($c, 2);
  animation-delay: 
    calc((.5*(var(--abs-i) + var(--abs-j))/var(--m) - 1)*#{$t});
}

@keyframes p { 0% { --p: 100%; } }
Sinkendes Gefühl (Live-Demo)

Dann haben wir eine doppelte Spirale aus winzigen Kugeln, bei denen sowohl der Kugeldurchmesser --d als auch der radiale Abstand --x, der zur Bestimmung der Kugelposition beiträgt, vom absoluten Wert --abs der Differenz zwischen dem Index jedes einzelnen, --i, und dem mittleren Index, --m, abhängen. Das Vorzeichen, --sgn, dieser Differenz wird verwendet, um die Richtung der Spiralrotation zu bestimmen. Dies hängt davon ab, wo sich jede Kugel relativ zur Mitte befindet – das heißt, ob ihr Index, --i, kleiner oder größer als der mittlere Index, --m, ist.

/* relevant styles */
--m: calc(.5*(var(--p) - 1));
--abs: max(calc(var(--m) - var(--i)), calc(var(--i) - var(--m)));
--sgn: clamp(-1, var(--i) - var(--m), 1);
--d: calc(3px + var(--abs)/var(--p)*#{$d}); /* sphere diameter */
--a: calc(var(--k)*1turn/var(--n-dot)); /* angle used to determine sphere position */
--x: calc(var(--abs)*2*#{$d}/var(--n-dot)); /* how far from spiral axis */
--z: calc((var(--i) - var(--m))*2*#{$d}/var(--n-dot)); /* position with respect to screen plane */
width: var(--d); height: var(--d);
transform: 
  /* change rotation direction by changing x axis direction */
  scalex(var(--sgn)) 
  rotate(var(--a)) 
  translate3d(var(--x), 0, var(--z)) 
  /* reverse rotation so the sphere is always seen from the front */
  rotate(calc(-1*var(--a))); 
  /* reverse scaling so lighting on sphere looks consistent */
  scalex(var(--sgn))
Keine Perspektive (Live-Demo)

Schließlich haben wir ein Gitter aus nicht-quadratischen Boxen mit einem border. Diese Boxen haben eine mask, die mit einem conic-gradient mit einem animierten Startwinkel, --ang, erstellt wurde. Ob diese Boxen horizontal oder vertikal gespiegelt werden, hängt davon ab, wo sie sich relativ zur Mitte befinden – das heißt, von den Vorzeichen der Differenzen zwischen den Spalten---i und Zeilen---j-Indizes und dem mittleren Index, --m. Die animation-delay hängt von den absoluten Werten dieser Differenzen ab und wird wie im vorherigen Abschnitt erklärt berechnet. Wir haben auch einen „gooey“ filter für einen schöneren „wurmigen“ Look, aber darauf gehen wir hier nicht ein.

/* relevant CSS */
$t: 1s;

@property --ang {
  syntax: '<angle>';
  initial-value: 0deg;
  inherits: false;
}

.box {
  --m: calc(.5*(var(--n) - 1));
  --dif-i: calc(var(--i) - var(--m));
  --dif-j: calc(var(--j) - var(--m));
  --abs-i: max(var(--dif-i), -1*var(--dif-i));
  --abs-j: max(var(--dif-j), -1*var(--dif-j));
  --sgn-i: clamp(-1, 2*var(--dif-i), 1);
  --sgn-j: clamp(-1, 2*var(--dif-j), 1);
  transform: scale(var(--sgn-i), var(--sgn-j));
  mask:
    repeating-conic-gradient(from var(--ang, 0deg), 
        red 0% 12.5%, transparent 0% 50%);
  animation: ang $t ease-in-out infinite;
  animation-delay: 
    calc(((var(--abs-i) + var(--abs-j))/var(--n) - 1)*#{$t});
}

@keyframes ang { to { --ang: .5turn; } }
Von Würmern verzehrt (Live-Demo)

Zeitformatierung (und mehr)

Nehmen wir an, wir haben ein Element, für das wir eine Anzahl von Sekunden in einer benutzerdefinierten Eigenschaft, --val, speichern, und wir möchten dies zum Beispiel im Format mm:ss anzeigen.

Wir verwenden den abgerundeten Wert des Verhältnisses zwischen --val und 60 (der Anzahl der Sekunden in einer Minute), um die Anzahl der Minuten zu erhalten, und den Modulo für die Anzahl der Sekunden nach dieser Anzahl von Minuten. Dann verwenden wir einen cleveren kleinen counter-Trick, um die formatierte Zeit in einem Pseudoelement anzuzeigen.

@property --min {
  syntax: '<integer>';
  initial-value: 0;
  inherits: false;
}

code {
  --min: calc(var(--val)/60 - .5);
  --sec: calc(var(--val) - var(--min)*60);
  counter-reset: min var(--min) sec var(--sec);
  
  &::after {
    /* so we get the time formatted as 02:09 */
    content: 
      counter(min, decimal-leading-zero) ':' 
      counter(sec, decimal-leading-zero);
  }
}

Dies funktioniert in den meisten Situationen, aber wir stoßen auf ein Problem, wenn --val genau 0 ist. In diesem Fall ist 0/60 0, und dann subtrahieren wir .5, wir erhalten -.5, das auf den größeren benachbarten Ganzzahlwert im absoluten Wert gerundet wird. Das heißt, -1, nicht 0! Das bedeutet, dass unser Ergebnis -01:60 sein wird, nicht 00:00!

Glücklicherweise haben wir eine einfache Lösung, und das ist, die Formel zur Ermittlung der Minuten, --min, leicht zu ändern.

--min: max(0, var(--val)/60 - .5);

Es gibt auch andere Formatierungsoptionen, wie unten gezeigt.

/* shows time formatted as 2:09 */
content: counter(min) ':' counter(sec, decimal-leading-zero);

/* shows time formatted as 2m9s */
content: counter(min) 'm' counter(sec) 's';

Wir können die gleiche Technik anwenden, um die Zeit als hh:mm:ss zu formatieren (Live-Test).

@property --hrs {
  syntax: '<integer>';
  initial-value: 0;
  inherits: false;
}

@property --min {
  syntax: '<integer>';
  initial-value: 0;
  inherits: false;
}

code {
  --hrs: max(0, var(--val)/3600 - .5);
  --mod: calc(var(--val) - var(--hrs)*3600);
  --min: max(0, var(--mod)/60 - .5);
  --sec: calc(var(--mod) - var(--min)*60);
  counter-reset: hrs var(--hrs) var(--min) sec var(--sec);
  
  &::after {
    /* so we get the time formatted as 00:02:09 */
    content: 
      counter(hrs, decimal-leading-zero) ':' 
      counter(min, decimal-leading-zero) ':' 
      counter(sec, decimal-leading-zero);
  }
}

Dies ist eine Technik, die ich zur Formatierung des output von nativen Bereichsschiebereglern verwendet habe, wie dem unten stehenden.

Screenshot showing a styled slider with a tooltip above the thumb indicating the elapsed time formatted as mm:ss. On the right of the slider, there's the remaining time formatted as -mm:ss.
Formatierter Bereichseingabewert, der die Zeit anzeigt (Live-Demo)

Zeit ist nicht das Einzige, wofür wir dies verwenden können. Zählerwerte müssen ganzzahlige Werte sein, was bedeutet, dass der Modulotrick auch zur Anzeige von Dezimalzahlen nützlich ist, wie im zweiten Slider unten.

Screenshot showing three styled sliders withe second one having a tooltip above the thumb indicating the decimal value.
Formatierte Range-Inputs, von denen einer einen Dezimalwert ausgibt (Live-Demo)

Ein paar weitere Beispiele dieser Art

Screenshot showing multiple styled sliders with the third one being focused and showing a tooltip above the thumb indicating the decimal value.
Formatierte Range-Inputs, von denen einer einen Dezimalwert ausgibt (Live-Demo)
Screenshot showing two styled sliders with the second one being focused and showing a tooltip above the thumb indicating the decimal value.
Formatierte Range-Inputs, von denen einer einen Dezimalwert ausgibt (Live-Demo)

Noch mehr Anwendungsfälle

Nehmen wir an, wir haben einen Lautstärkeregler mit einem Symbol an jedem Ende. Je nachdem, in welche Richtung wir den Schieberegler bewegen, wird eines der beiden Symbole hervorgehoben. Dies ist möglich, indem der Absolutwert, --abs, der Differenz zwischen dem Vorzeichen jedes Symbols, --sgn-ico (-1 für das Symbol vor dem Regler und 1 für das Symbol nach dem Regler), und dem Vorzeichen der Differenz, --sgn-dir, zwischen dem aktuellen Wert des Reglers, --val, und seinem vorherigen Wert, --prv, ermittelt wird. Wenn dies 0 ist, bewegen wir uns in Richtung des aktuellen Symbols, also setzen wir seine opacity auf 1. Andernfalls bewegen wir uns vom aktuellen Symbol weg, also behalten wir seine opacity bei .15.

Das bedeutet, dass wir bei jeder Änderung des Werts des Range-Inputs nicht nur seinen aktuellen Wert, --val, auf seinem Elternelement aktualisieren müssen, sondern auch seinen vorherigen Wert, der eine weitere benutzerdefinierte Eigenschaft ist, --prv, auf demselben übergeordneten Wrapper aktualisieren müssen.

addEventListener('input', e => {
  let _t = e.target, _p = _t.parentNode;
	
  _p.style.setProperty('--prv', +_p.style.getPropertyValue('--val'))
  _p.style.setProperty('--val', +_t.value)
})

Das Vorzeichen ihrer Differenz ist das Vorzeichen der Richtung, --sgn-dir, in die wir uns bewegen, und das aktuelle Symbol wird hervorgehoben, wenn sein Vorzeichen, --sgn-ico, und das Vorzeichen der Richtung, in die wir uns bewegen, --sgn-dir, übereinstimmen. Das heißt, wenn der Absolutwert, --abs, ihrer Differenz 0 ist und gleichzeitig das übergeordnete Wrapper-Element ausgewählt ist (entweder wird es angefahren oder der Range-input darin hat den Fokus).

[role='group'] {
  --dir: calc(var(--val) - var(--prv));
  --sgn-dir: clamp(-1, var(--dir), 1);
  --sel: 0; /* is the slider focused or hovered? Yes 1/ No 0 */
  
  &:hover, &:focus-within { --sel: 1; }
}

.ico {
  --abs: max(var(--sgn-dir) - var(--sgn-ico), var(--sgn-ico) - var(--sgn-dir));
  --hlg: calc(var(--sel)*(1 - min(1, var(--abs)))); /* highlight current icon? Yes 1/ No 0 */
  opacity: calc(1 - .85*(1 - var(--hlg)));
}

Ein weiterer Anwendungsfall ist, die Eigenschaftswerte von Elementen in einem Gitter vom Paritätswert der Summe der horizontalen --abs-i und vertikalen --abs-j Abstände zum Mittelpunkt, --m, abhängig zu machen. Zum Beispiel machen wir dies für die background-color

@property --floor {
  syntax: '<integer>';
  initial-value: 0;
  inherits: false;
}

.cell {
  --m: calc(.5*(var(--n) - 1));
  --abs-i: max(var(--m) - var(--i), var(--i) - var(--m));
  --abs-j: max(var(--m) - var(--j), var(--j) - var(--m));
  --sum: calc(var(--abs-i) + var(--abs-j));
  --floor: max(0, var(--sum)/2 - .5);
  --mod: calc(var(--sum) - var(--floor)*2);
  background: hsl(calc(90 + var(--mod)*180), 50%, 65%);
}
Screenshot showing a 16x16 grid where each tile is either lime or purple.
Hintergrund basierend auf der Parität der Summe der horizontalen und vertikalen Abstände zum Mittelpunkt (Live-Demo)

Wir können die Dinge aufpeppen, indem wir den Modulo 2 des Bodens der Summe geteilt durch 2 verwenden

@property --floor {
  syntax: '<integer>';
  initial-value: 0;
  inherits: false;
}

@property --int {
  syntax: '<integer>';
  initial-value: 0;
  inherits: false;
}

.cell {
  --m: calc(.5*(var(--n) - 1));
  --abs-i: max(var(--m) - var(--i), var(--i) - var(--m));
  --abs-j: max(var(--m) - var(--j), var(--j) - var(--m));
  --sum: calc(var(--abs-i) + var(--abs-j));
  --floor: max(0, var(--sum)/2 - .5);
  --int: max(0, var(--floor)/2 - .5);
  --mod: calc(var(--floor) - var(--int)*2);
  background: hsl(calc(90 + var(--mod)*180), 50%, 65%);
}
Screenshot showing a 16x16 grid where each tile is either lime or purple.
Eine interessantere Variante der vorherigen Demo (Live-Demo)

Wir könnten auch sowohl die Richtung einer Rotation als auch die einer conic-gradient() von derselben Parität der Summe, --sum, der horizontalen --abs-i und vertikalen --abs-j Abstände zum Mittelpunkt, --m, abhängig machen. Dies wird erreicht, indem das Element horizontal gespiegelt wird, wenn die Summe, --sum, gerade ist. Im folgenden Beispiel werden die Rotation und die Größe ebenfalls über Houdini animiert (beide hängen von einer benutzerdefinierten Eigenschaft, --f, ab, die wir registrieren und dann von 0 auf 1 animieren), ebenso wie der Wurm-Farbton, --hue, und die conic-gradient() Maske, beide Animationen haben eine Verzögerung, die genau wie in früheren Beispielen berechnet wird.

@property --floor {
  syntax: '<integer>';
  initial-value: 0;
  inherits: false;
}

.🐛 {
  --m: calc(.5*(var(--n) - 1));
  --abs-i: max(var(--m) - var(--i), var(--i) - var(--m));
  --abs-j: max(var(--m) - var(--j), var(--j) - var(--m));
  --sum: calc(var(--abs-i) + var(--abs-j));
  --floor: calc(var(--sum)/2 - .5);
  --mod: calc(var(--sum) - var(--floor)*2);
  --sgn: calc(2*var(--mod) - 1); /* -1 if --mod is 0; 1 id --mod is 1 */
  transform: 
    scalex(var(--sgn)) 
    scale(var(--f)) 
    rotate(calc(var(--f)*180deg));
  --hue: calc(var(--sgn)*var(--f)*360);
}
Grid wave: dreieckige Regenbogenwürmer (Live-Demo).

Schließlich ist ein weiterer wichtiger Anwendungsfall der bisher erklärten Techniken die Schattierung nicht nur konvexer, sondern auch konkav animierter 3D-Formen mit absolut keinem JavaScript! Dies ist ein Thema, das für sich genommen riesig ist und dessen vollständige Erklärung einen Artikel so lang wie diesen erfordern würde, daher werde ich hier überhaupt nicht darauf eingehen. Aber ich habe ein paar Videos gemacht, in denen ich ein paar solcher einfachen reinen CSS 3D-Formen (einschließlich eines hölzernen Sterns und eines anders geformten metallischen Sterns) von Grund auf neu kodiert habe, und Sie können natürlich auch den CSS-Code für das folgende Beispiel auf CodePen einsehen.

Musikalisches Spielzeug (Live-Demo)