Startseite / Artikel

CSS-Custom-Eigenschaften zur Laufzeit: Theming, JS-Brücken und @property

Erfahren Sie, wie sich CSS-Custom-Properties von Sass-Variablen unterscheiden, und nutzen Sie sie für Themengestaltung, durch JavaScript gesteuerte Effekte, fließende Schriftgrößen, Komponentenvarianten sowie typisierte Animationen.

Verlag: REACTAPP.TOP17 Min. Lesezeit3986 Wörter

Quellen: CSS Variables Are Underrated — Here’s What You’re Missing

Viele Codebasen verwenden CSS-Custom-Properties genauso wie Sass-Variablen: eine Liste von Farben, die einmal in :root deklariert werden und danach nicht mehr verändert werden. Das funktioniert zwar, ignoriert aber die Eigenschaft, die sie voneinander unterscheidet. Custom Properties sind laufende Werte im Browser, die sich über das DOM fortpflanzen, auf Media Queries sowie Änderungen von Attributen reagieren und jederzeit über JavaScript aktualisiert werden können. Diese Anleitung zeigt, was dieses Laufzeitverhalten in der Praxis ermöglicht – von Themen und parametrisierten Komponenten bis hin zu typisierten Animationen mit @property – und weist auf die Mechanismen sowie Fallstricke hin, die Teams behindern können.

Kompilierzeitkonstanten gegenüber laufenden Werten

Präprozessoren wie Sass, Less und Stylus haben jahrelang die Gestaltung der Frontend-Oberflächen mithilfe von Nesting, Mixins und Variablen geprägt. Als native benutzerdefinierte Eigenschaften in den Browsern eingeführt wurden, war die gängige Reaktion, dass sie nichts hinzufügten, was $primary-color bereits nicht bieten würde. Diese Annahme ist der Grund für die untergeordnete Nutzung. Der Unterschied zeigt sich, wenn der Wert vorhanden ist:

+-------------------------------------------------------------------+
|                     SASS / SCSS VARIABLES                         |
|  Compile-Time Constants  -->  Evaluated Before Ship  -->  Static   |
+-------------------------------------------------------------------+

+-------------------------------------------------------------------+
|                    CSS CUSTOM PROPERTIES                          |
|  Runtime Live Properties --> Evaluated in Browser --> Dynamic     |
+-------------------------------------------------------------------+

Was aus einer Präprozessorvariablen wird

Eine Sass-Variable wird bereits vor dem Erreichen des Browsers durch das CSS gelöst. Im folgenden SCSS wird innerhalb des dark-Modifikatorblocks ein zweiter Wert zugewiesen:

$brand-color: #3b82f6;

.card {
  background-color: $brand-color;
}

.card--dark {
  $brand-color: #1e293b;
  background-color: $brand-color;
}

Der Compiler ersetzt den literalen Wert an allen Stellen, an denen die Variable verwendet wird, sodass die bereitgestellte Stylesheet-Datei im Grunde genommen wie folgt aussieht:

.card {
  background-color: #3b82f6;
}

.card--dark {
  background-color: #1e293b;
}

Nach der Kompilierung existiert der Name $brand-color nicht mehr. Der Browser kann nicht erkennen, dass beide Farben von derselben Variable stammen, und er kann diesen Wert auch nicht aufgrund eines Klicks, einer Änderung des Ansichtsfensters oder eines Hover-Zustands ändern. Jede Variation muss im Voraus als zusätzlicher CSS-Code erstellt werden.

Was von einer benutzerdefinierten Eigenschaft übrig bleibt

Die native Version hält den Namen im Browser am Leben:

:root {
  --brand-color: #3b82f6;
}

.card {
  background-color: var(--brand-color);
}

.card[data-theme="dark"] {
  --brand-color: #1e293b;
}

Hier ist --brand-color Teil der berechneten Styles jeder Elemente. Wenn ein .card den Wert data-theme="dark" erhält, berechnet der Browser erneut var(--brand-color) für dieses Card-Element sowie seine Nachkommen. Die Regel für .card wird nicht dupliziert; man ändert lediglich den Wert für einen bestimmten Zweig des DOM-Baums. Kurz gesagt: Sass-Variablen werden während der Kompilierung ersetzt und laufenzeitbedingt nicht mehr vorhanden, während benutzerdefinierte Eigenschaften vom Browser pro Element aus gelöst werden, am Kaskadensystem teilnehmen und sich nach dem Laden der Seite ändern können.

Die Mechanismen, die Sie richtig verstehen müssen

Mehrere Regeln zu Syntax, Gültigkeitsbereich und Fehlerbehandlung erklären das meisten überraschenden Verhaltens, mit dem Menschen konfrontiert werden.

Namengebung und Lesen von Werten

Der Name einer benutzerdefinierten Eigenschaft muss mit zwei Bindestrichen beginnen, und die Namen sind groß-/kleinschreibsensitiv – daher handelt es sich um zwei separate Eigenschaften:

:root {
  --main-color: #10b981;
  --Main-Color: #ef4444; /* Different property! */
}

Man liest einen Wert mit der var()-Funktion:

.button {
  background-color: var(--main-color);
}

Der Gültigkeitsbereich folgt der Kaskade

Custom Properties unterliegen der normalen Kaskade und Vererbung. Wenn man eine solche Property in :root deklariert, was dem <html>-Element entspricht, ist sie überall verfügbar. Eine Deklaration in einem anderen Selektor überschreibt sie nur für dieses Element und seine Nachkommen:

/* Global Scope */
:root {
  --spacing-unit: 1rem;
}

/* Sub-tree Scope Override */
.sidebar {
  --spacing-unit: 0.5rem; /* Overrides --spacing-unit for .sidebar and its descendants */
}

.sidebar .widget {
  padding: var(--spacing-unit); /* Computes to 0.5rem */
}

.main-content .widget {
  padding: var(--spacing-unit); /* Computes to 1rem */
}

Sowohl Widgets verwenden dieselbe Regel, doch das Widget innerhalb von .sidebar erhält einen kleineren Abstand, weil die nächstgelegene Deklaration Vorrang hat. Genau das macht scopebasierte Überschreibungen so einfach: Man ändert einen Wert, nicht eine Regel.

Ersatzwerte

var() akzeptiert ein zweites Argument, das verwendet wird, wenn die referenzierte Property keinen Wert hat. Ersatzwerte können selbst var()-Aufrufe enthalten, sodass man sie verschachteln kann:

.avatar {
  /* Fallback to standard 40px if --avatar-size is undefined */
  width: var(--avatar-size, 40px);

  /* Nested fallbacks */
  color: var(--user-theme-color, var(--brand-default, #2563eb));
}

Seien Sie präzise bezüglich des Zeitpunkts, an dem ein Fallback angewendet wird. Er wird nur dann verwendet, wenn die benannte Eigenschaft für dieses Element nicht definiert ist (auch nicht von einem Vorfahren vererbt) oder ausdrücklich auf initial gesetzt wurde. Wenn die Eigenschaft zwar definiert ist, aber einen Wert enthält, der für das Ziel keinen Sinn ergibt – beispielsweise eine Länge anstelle einer Farbe – wird der Fallback ignoriert. Beachten Sie, dass unset keine zuverlässige Methode ist, um ihn auszulösen, da benannte Eigenschaften standardmäßig vererbt werden; daher verhält sich unset wie inherit und übernimmt den Wert des Elternelements, sofern dieser vorhanden ist.

Ungültig zur Zeit der Berechnung des Wertes

Eine gewöhnliche Deklaration mit ungültigem Wert wird bereits zur Zeit der Parsing-Auswertung verworfen, sodass weiterhin eine frühere gültige Deklaration gilt. Deklarationen mit var() können während des Parsing nicht überprüft werden, da der ersetze Wert erst nach Ausführung der Kaskadierung bekannt ist:

.box {
  color: red;
  color: var(--not-a-color, 12px); /* Invalid type for color! */
}

Der Parser akzeptiert die zweite color-Zeile und überschreibt damit color: red. Erst bei der Berechnung des computed-value stellt der Browser fest, dass 12px keine Farbe ist. Zu diesem Zeitpunkt hat die frühere Deklaration bereits keine Wirkung mehr, wodurch die Eigenschaft so handelt, als wäre sie unset: Erbeigene Eigenschaften wie color übernehmen den Wert des Elternelements, während nicht-erbeigene Eigenschaften auf ihren ursprünglichen Wert zurückgreifen (zum Beispiel transparent für background-color). Dieser stillschweigende Fehler ist eines der Probleme, die @property, das später behandelt wird, lösen soll.

Themen ohne doppelte Regeln

Eine klassische Umsetzung eines Dark-Mode kopiert jede themenspezifische Regel doppelt:

/* Traditional approach: Duplicating declarations */
.card {
  background-color: #ffffff;
  color: #1e293b;
  border: 1px solid #e2e8f0;
  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
}

body.dark-mode .card {
  background-color: #0f172a;
  color: #f8fafc;
  border: 1px solid #334155;
  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.5);
}

Mit nur wenigen Eigenschaften ist das zu bewältigen. Bei Dutzenden von Komponenten, jede mit vielen themenspezifischen Deklarationen, vervielfacht sich bei jedem Thema die Anzahl der Selektoren, die synchron gehalten werden müssen.

Tokens machen Komponenten themenunabhängig

Falls die Gestaltungsentscheidungen in benannten Eigenschaften liegen, kümmern sich die Komponentenregeln nicht mehr darum, welches Thema aktiv ist:

/* 1. Define Design Tokens */
:root {
  --bg-surface: #ffffff;
  --text-primary: #1e293b;
  --border-subtle: #e2e8f0;
  --shadow-alpha: 0.05;
}

[data-theme="dark"] {
  --bg-surface: #0f172a;
  --text-primary: #f8fafc;
  --border-subtle: #334155;
  --shadow-alpha: 0.5;
}

/* 2. Write Component CSS Once */
.card {
  background-color: var(--bg-surface);
  color: var(--text-primary);
  border: 1px solid var(--border-subtle);
  box-shadow: 0 4px 6px rgba(0, 0, 0, var(--shadow-alpha));
}

Die .card-Regel existiert nur einmal. Durch Hinzufügen eines dritten Themas, wie zum Beispiel [data-theme="high-contrast"], müssen vier Tokenwerte bereitgestellt werden, anstatt für jede Komponente erneut Regeln zu deklarieren.

Eine Palette aus Farbkanälen ableiten

Man kann dies noch weiter gehen, indem man die einzelnen Kanäle einer Farbe speichert – beispielsweise im OKLCH-Format – und daraus alle Variationen berechnet:

:root {
  /* Store raw OKLCH channels: Lightness, Chromaticity, Hue */
  --brand-l: 0.62;
  --brand-c: 0.19;
  --brand-h: 255;

  /* Base dynamic color */
  --brand-color: oklch(var(--brand-l) var(--brand-c) var(--brand-h));

  /* Automatically derived variations */
  --brand-hover: oklch(calc(var(--brand-l) - 0.1) var(--brand-c) var(--brand-h));
  --brand-active: oklch(calc(var(--brand-l) - 0.2) var(--brand-c) var(--brand-h));
  --brand-tint: oklch(0.95 calc(var(--brand-c) * 0.2) var(--brand-h));
  --brand-alpha-50: oklch(var(--brand-l) var(--brand-c) var(--brand-h) / 0.5);
}

.button-primary {
  background-color: var(--brand-color);
  color: white;
}

.button-primary:hover {
  background-color: var(--brand-hover);
}

.button-primary:active {
  background-color: var(--brand-active);
}

.badge {
  background-color: var(--brand-tint);
  color: var(--brand-color);
}

Die Varianten Hover, Active, Tint und Translucent sind alle Ausdrücke, die aus drei Zahlen bestehen. Durch Änderung von --brand-h in :root, sei es über JavaScript oder Benutzerpräferenzen, wird die Grundfarbe sowie ihre Zustände, die Tönung und die Überlagerungen einheitlich neu gefärbt – ohne Verwendung einer Farbmanipulationsbibliothek.

Bei jedem abgeleiteten Token gibt es eine Besonderheit: Ein var() innerhalb einer benutzerdefinierten Eigenschaft wird am Element gelöst, an dem diese Eigenschaft deklariert ist, und Nachkommen erben den daraus resultierenden Wert. Da --brand-color und ähnliche Werte in :root deklariert sind, führt das Überschreiben von --brand-h weiter unten im Baum nicht dazu, dass diese Werte dort neu berechnet werden. Ändern Sie entweder den Wert in :root oder deklarieren Sie die abgeleiteten Token erneut im Scope, in dem Sie den Wert überschreiben.

CSS aus JavaScript steuern

Custom Properties bieten eine saubere Schnittstelle zwischen Scripts und Styles. Anstatt inline-Style-Strings zu schreiben oder mit Klassenkombinationen umzugehen, kann JavaScript rohe Werte wie Zeigerkoordinaten oder Anwendungsstatus an CSS übergeben und die Stylesheet-Datei entscheiden lassen, was damit gemacht werden soll.

Hervorhebung, die dem Cursor folgt

Fangen wir mit einem Card-Element an:

<div class="glow-card" id="card">
  <h3>Interactive Reactivity</h3>
  <p>Hover over this card to see real-time cursor lighting.</p>
</div>

Die Stylesheet-Datei definiert Standardkoordinaten und verwendet diese, um einen radialen Gradienten in einem Pseudo-Element zu platzieren:

.glow-card {
  --mouse-x: 50%;
  --mouse-y: 50%;

  position: relative;
  background-color: #1e1e2e;
  border-radius: 12px;
  padding: 2rem;
  color: #cdd6f4;
  overflow: hidden;
}

/* Radial light overlay following cursor coordinates */
.glow-card::before {
  content: "";
  position: absolute;
  inset: 0;
  background: radial-gradient(
    800px circle at var(--mouse-x) var(--mouse-y),
    rgba(255, 255, 255, 0.08),
    transparent 40%
  );
  pointer-events: none;
}

Das Script gibt lediglich an, wo sich der Zeiger relativ zum Card-Element befindet:

const card = document.getElementById('card');

card.addEventListener('pointermove', (e) => {
  const rect = card.getBoundingClientRect();
  const x = e.clientX - rect.left;
  const y = e.clientY - rect.top;

  // Send raw coordinates to CSS
  card.style.setProperty('--mouse-x', `${x}px`);
  card.style.setProperty('--mouse-y', `${y}px`);
});

Der Reiz dieser Arbeitsteilung besteht darin, dass JavaScript zwei Zahlen auf ein Element schreibt und den Gradienten selbst niemals berührt. Es ist jedoch nicht kostenlos: Jede Aktualisierung löst eine erneute Berechnung des Styles für die Karte aus, und da sich der Gradient ändert, wird die Überlagerung neu gemalt. Dieser Aufwand ist für ein einzelnes Element gering, weshalb die Wirkung in der Regel reibungslos abläuft – es handelt sich jedoch nicht um eine „Null-Reflow“-Verarbeitung, die außerhalb des Hauptthreads stattfindet.

Eine Scroll-Progressleiste

Dieselbe Idee kann die Scrollposition als zahlenlose Bruchzahl zwischen 0 und 1 an CSS weitergeben:

window.addEventListener('scroll', () => {
  const scrollTop = window.scrollY;
  const docHeight = document.documentElement.scrollHeight - window.innerHeight;
  const scrollPercent = (scrollTop / docHeight);

  // Set unitless fraction (0 to 1)
  document.documentElement.style.setProperty('--scroll-progress', scrollPercent);
});

Die Leiste nutzt diese Position über transform auf, wodurch Layout-Arbeiten vermieden werden, da das Skalieren die Größe aller anderen Elemente auf der Seite nicht verändert:

/* Progress indicator bar */
.scroll-progress-bar {
  position: fixed;
  top: 0;
  left: 0;
  height: 4px;
  width: 100%;
  background-color: #3b82f6;
  transform-origin: left;
  /* Hardware-accelerated GPU transform using variable */
  transform: scaleX(var(--scroll-progress, 0));
  will-change: transform;
}

Zwei Verbesserungen sind zu berücksichtigen. Das Skript schreibt in document.documentElement, was genau die globale, häufige Aktualisierung ist, vor der im Abschnitt zur Leistung gewarnt wird; die Festlegung des Wertes direkt auf der Statusleiste beschränkt die Neuberechnung auf ein einzelnes Element. In Browsern, die CSS-basierte Scroll-Animationen unterstützen, kann eine solche Fortschrittsleiste mithilfe von animation-timeline: scroll() völlig ohne JavaScript erstellt werden.

Fließende Typografie und Skalierungsverhältnisse

Typografien, die auf Breakpoints basieren, wechseln bei Breiten wie 768px und 1024px zwischen festen Größen. Durch die Kombination von benutzerdefinierten Eigenschaften mit clamp() und calc() können die Größen hingegen kontinuierlich zwischen einem Minimum und einem Maximum variieren.

Eine parametrisierte clamp()-Berechnung

Die Idee besteht darin, das Maß in einfachen Zahlen (Betrachtungsbereich des Bildschirms und Schriftgrößenbereich) zu beschreiben und anschließend die Steigung sowie den Schnittpunkt einer linearen Funktion zwischen ihnen zu ermitteln:

:root {
  /* Viewport and layout parameters */
  --fluid-min-width: 320;
  --fluid-max-width: 1200;

  /* Typography scale parameters */
  --font-min-size: 16;
  --font-max-size: 20;

  /* Compute slope and intersection for clamp() */
  --slope: (var(--font-max-size) - var(--font-min-size)) / (var(--fluid-max-width) - var(--fluid-min-width));
  --y-axis-intersection: -1 * var(--fluid-min-width) * var(--slope) + var(--font-min-size);

  /* Fluid base font size expression */
  --fluid-type-base: clamp(
    calc(var(--font-min-size) * 1px),
    calc(var(--y-axis-intersection) * 1px + (var(--slope) * 100vw)),
    calc(var(--font-max-size) * 1px)
  );
}

body {
  font-size: var(--fluid-type-base);
}

Betrachten Sie diesen Codeausschnitt als Ausgangspunkt und nicht als funktionsfähigen Code. Die Ersetzung von Eigenschaften erfolgt textbasiert, wodurch, wenn --y-axis-intersection in calc(var(--y-axis-intersection) * 1px + ...) eingefügt wird, das * 1px nur auf den letzten Term dieser Ausdrucksreihe angewendet wird. Der maßlose erste Term wird anschließend zu einer Länge hinzugefügt, was die gesamte clamp()-Funktion ungültig macht. Durch Einfügen von Klammern um die Zwischenausdrücke, beispielsweise --y-axis-intersection: (-1 * var(--fluid-min-width) * var(--slope) + var(--font-min-size));, oder innerhalb von calc(), bleibt die gewünschte Gruppierung erhalten.

Harmonische Überschriftengrößen aus einem Verhältnis

Sobald eine Basisgröße festgelegt ist, kann ein einziger Verhältniswert die gesamte Überschriftsgrößenskala erzeugen:

:root {
  /* Scale multiplier ratio (e.g., 1.25 Major Third) */
  --type-ratio: 1.25;

  --step-0: var(--fluid-type-base);
  --step-1: calc(var(--step-0) * var(--type-ratio));
  --step-2: calc(var(--step-1) * var(--type-ratio));
  --step-3: calc(var(--step-2) * var(--type-ratio));
  --step-4: calc(var(--step-3) * var(--type-ratio));
}

/* Application across elements */
p  { font-size: var(--step-0); }
h3 { font-size: var(--step-1); }
h2 { font-size: var(--step-2); }
h1 { font-size: var(--step-3); }

Jeder Schritt multipliziert den vorherigen, sodass die Proportionen zwischen den Ebenen konstant bleiben. Das Ändern des Verhältniswerts in einer Media-Abfrage berechnet alle Ebenen gleichzeitig neu:

@media (min-width: 1024px) {
  :root {
    /* Upgrade to 1.333 Perfect Fourth on large displays */
    --type-ratio: 1.333;
  }
}

Das funktioniert, weil die Schritte sowie das Verhältnis auf demselben Element, :root, deklariert sind, wodurch die Änderung sichtbar wird, wenn die Schritte berechnet werden.

Komponenten mit Parametern anstelle von Variante-Klassen

Variante-Klassen in Methoden wie BEM neigen dazu, sich zu vermehren: Jede Größe, Form und Farbe wird zu einem weiteren Block von Deklarationen, die den Basiswert teilweise wiederholen.

/* Traditional class bloat */
.btn { padding: 8px 16px; font-size: 14px; background: blue; border-radius: 4px; }
.btn--md { padding: 12px 24px; font-size: 16px; }
.btn--lg { padding: 16px 32px; font-size: 18px; }
.btn--rounded { border-radius: 9999px; }
.btn--danger { background: red; }
.btn--success { background: green; }

Custom Properties als API einer Komponente

Ein robusterer Ansatz betrachtet benutzerdefinierte Eigenschaften als Konfiguration der Komponente. Die Grundregel liest jeden einstellbaren Wert aus einer Eigenschaft mit Standardwert, während Modifikatoren nur Parameter ändern:

.btn {
  /* 1. Component Defaults */
  --btn-bg: #3b82f6;
  --btn-color: #ffffff;
  --btn-px: 1.25rem;
  --btn-py: 0.625rem;
  --btn-radius: 0.375rem;
  --btn-font-size: 0.875rem;

  /* 2. Unified Rule Declaration */
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: var(--btn-py) var(--btn-px);
  background-color: var(--btn-bg);
  color: var(--btn-color);
  border-radius: var(--btn-radius);
  font-size: var(--btn-font-size);
  border: none;
  cursor: pointer;
  transition: background-color 0.2s, transform 0.1s;
}

.btn:hover {
  filter: brightness(0.9);
}

/* 3. Concise Declarative Modifiers */
.btn--lg {
  --btn-px: 1.75rem;
  --btn-py: 0.875rem;
  --btn-font-size: 1.125rem;
}

.btn--pill {
  --btn-radius: 9999px;
}

.btn--danger {
  --btn-bg: #ef4444;
}

.btn--ghost {
  --btn-bg: transparent;
  --btn-color: #3b82f6;
}

Modifikatoren wie .btn--lg wiederholen display, border-radius, cursor oder transition nicht. Sie setzen lediglich neue Werte für die Parameter fest. Die Stylesheet bleibt klein, Modifikatoren lassen sich nahtlos kombinieren (btn--lg btn--pill btn--danger funktioniert einfach), und wenn eine Variante falsch aussieht, weiß man, dass es nur eines von wenigen Modifikatoren sein kann.

Typisierte Eigenschaften und Animation mit @property

Ungeregistrierte benutzerdefinierte Eigenschaften weisen eine bekannte Einschränkung auf: Sie können nicht in Übergängen oder Keyframes interpoliert werden. Der Browser behandelt ihre Werte als untypisierte Tokenfolgen, weshalb es keine Möglichkeit gibt, von 0deg auf 360deg oder von red auf blue zu animieren; der Wert wechselt einfach zu einem bestimmten Zeitpunkt während der Animation.

Die at-Regel @property registriert eine benutzerdefinierte Eigenschaft mit einer Syntax, einem Anfangswert und einer Erberegelung. Sie wird in aktuellen Versionen der wichtigsten Browser unterstützt, doch prüfen Sie Ihre Zielplattformen vor dem Einsatz darauf.

Registrierung typisierter Eigenschaften

/* Register type-checked custom property */
@property --gradient-angle {
  syntax: "<angle>";
  inherits: false;
  initial-value: 0deg;
}

@property --accent-color {
  syntax: "<color>";
  inherits: true;
  initial-value: #3b82f6;
}

Durch die Registrierung wird außerdem das Problem fehlerloser Fehlerbehandlung aus früheren Versionen behoben: Ein Wert, der nicht zur deklarierten Syntax passt, wird abgelehnt, und die Eigenschaft verwendet stattdessen ihren registrierten Anfangswert anstelle eines unerwarteten, ererbten Wertes.

Übergänge auf Gradienten

Sobald der Browser weiß, dass --gradient-angle ein Winkel und --accent-color eine Farbe ist, kann er diese interpolieren, wodurch ein Gradientenübergang möglich wird:

.animated-gradient-card {
  /* Reference the typed property inside radial/linear gradient */
  background: linear-gradient(
    var(--gradient-angle),
    #0f172a,
    var(--accent-color),
    #0f172a
  );

  border-radius: 16px;
  padding: 3rem;

  /* Animate custom property on hover! */
  transition: --gradient-angle 0.8s ease, --accent-color 0.5s ease;
}

.animated-gradient-card:hover {
  --gradient-angle: 180deg;
  --accent-color: #ec4899;
}

Kontinuierliche Keyframe-Animation

Durch dieselbe Registrierung können Keyframes verwendet werden, die einen Gradienten unbegrenzt drehen:

@keyframes spin-gradient {
  to {
    --gradient-angle: 360deg;
  }
}

.glowing-border {
  animation: spin-gradient 4s linear infinite;
}

Beachten Sie, dass die Animation einer in einem Gradienten verwendeten registrierten Eigenschaft den Element auf jeder Frame immer noch auf der Hauptthread neu zeichnet; das ist nicht dasselbe wie eine rein im Kompositor laufende transform- oder opacity-Animation.

Leistung: Wo Updates schaden

Kundeneigenschaften sind an sich nicht langsam. Die Kosten hängen davon ab, wie viel des Baums durch ein Update ungültig wird.

Bis wohin ein Update reicht

Die Aufrufung von element.style.setProperty('--name', value) führt zu einer Ungültigkeitsmarkierung des Styles für dieses Element sowie für alles, was davon erben könnte:

+-------------------------------------------------------------------------+
|                  STYLE INVALIDATION CASCADE PATTERN                     |
|                                                                         |
|  [ :root Variable Mutation ]                                            |
|       └─> Invalidates Entire DOM Tree                                   |
|            └─> High Recalculation Cost (Large DOMs)                     |
|                                                                         |
|  [ Scoped Local Element Mutation ]                                      |
|       └─> Invalidates Only Local Component Branch                       |
|            └─> Minimal Engine Cost (60 FPS Safe)                        |
+-------------------------------------------------------------------------+
  • Die Änderung einer Eigenschaft in :root kann den Browser dazu zwingen, die Styles für jedes Element, das irgendwo im Dokument davon abhängt, erneut zu berechnen. Dies bei jedem scroll- oder mousemove-Ereignis auf einer großen Seite kann zu Leistungsverlusten führen.
  • Die Einstellung der Eigenschaft auf ein spezifisches Element, wie zum Beispiel das über das Mauszeiger angezeigte Karten-Element, beschränkt die Neuberechnung auf diesen Bereich.

Praktische Richtlinien

Zunächst sollten hochfrequente Aktualisierungen wie die Position des Zeigers oder der Scrollfortschritt an den Komponenten gesendet werden, die sie verwenden, und nicht an :root.

Zweitens sollten Werte, die von keinem Nachkommen benötigt werden, mit inherits: false registriert werden, da dies die Ausbreitung von Änderungen während der Animation einschränkt:

@property --local-x {
  syntax: "<length>";
  inherits: false; /* Performance booster! Stops inheritance traversal */
  initial-value: 0px;
}

Es gibt eine wichtige Einschränkung: Pseudo-Elemente erben von ihrem ursprünglichen Element, sodass eine nicht weitergegebene, registrierte Eigenschaft, die auf einem Element gesetzt wurde, innerhalb von ::before oder ::after dieses Elements nicht sichtbar ist. Wenn ein Pseudo-Element den Wert abruft, sollte die Eigenschaft weitergegeben bleiben oder der Effekt direkt auf dem Element selbst angewendet werden.

Drittens sollte ein Wert, der Bewegungen auslöst, über transform oder opacity verarbeitet werden, damit die Änderung den Layout-Prozess sowie gegebenenfalls auch das Zeichnen umgeht:

/* Performant GPU layer usage */
.box {
  transform: translate3d(var(--x, 0), var(--y, 0), 0);
}

Zusammenfassung: Eine themenbezogene Dashboard-Karte

Das folgende Widget kombiniert verschiedene Techniken: zwei Themes, ein dem Cursor folgender Glanzeffekt, eine Helligkeitssteuerung zur Neufärbung des Elements sowie dynamisch anpassbare Überschriftengrößen. Die Markup-Struktur besteht aus einem semantischen <article>, das einen Header, eine Schalterknopf und ein Eingabefeld für einen Wertebereich enthält:

<article class="dash-card" id="dashCard" data-theme="dark">
  <header class="dash-card__header">
    <span class="dash-card__badge">Live Metrics</span>
    <button class="dash-card__theme-btn" id="themeToggle">Toggle Theme</button>
  </header>

  <div class="dash-card__body">
    <h2 class="dash-card__title">CSS Variable Architecture</h2>
    <p class="dash-card__text">
      Hover over this component to trigger lighting updates. Use the color slider below to adjust primary hue channels across all child components in real time.
    </p>

    <div class="dash-card__controls">
      <label for="hueSlider">Primary Hue: <span id="hueVal">220</span>°</label>
      <input type="range" id="hueSlider" min="0" max="360" value="220">
    </div>
  </div>
</article>

Die Stylesheet-Struktur ist in drei Ebenen unterteilt: registrierte Eigenschaften und globale Token, Themespezifika auf der Karte sowie Komponentenregeln, die ausschließlich Token lesen:

/* ==========================================================================
   1. DESIGN TOKENS & SYSTEM SCALARS
   ========================================================================== */
@property --card-glow-x {
  syntax: "<length>";
  inherits: false;
  initial-value: 0px;
}

@property --card-glow-y {
  syntax: "<length>";
  inherits: false;
  initial-value: 0px;
}

:root {
  /* Default Color Channels */
  --hue: 220;
  --chroma: 0.18;

  /* Derived Dynamic Palette */
  --color-primary: oklch(0.60 var(--chroma) var(--hue));
  --color-primary-hover: oklch(0.50 var(--chroma) var(--hue));
  --color-primary-tint: oklch(0.95 calc(var(--chroma) * 0.3) var(--hue));

  /* Layout Dimensions */
  --radius-lg: 1rem;
  --radius-sm: 0.375rem;
}

/* ==========================================================================
   2. THEME SCOPES
   ========================================================================== */
.dash-card[data-theme="light"] {
  --bg-card: #ffffff;
  --bg-card-border: #e2e8f0;
  --text-main: #0f172a;
  --text-muted: #64748b;
  --glow-opacity: 0.12;
}

.dash-card[data-theme="dark"] {
  --bg-card: #0f172a;
  --bg-card-border: #1e293b;
  --text-main: #f8fafc;
  --text-muted: #94a3b8;
  --glow-opacity: 0.25;
}

/* ==========================================================================
   3. COMPONENT DECLARATION
   ========================================================================== */
.dash-card {
  position: relative;
  width: 100%;
  max-width: 480px;
  background-color: var(--bg-card);
  border: 1px solid var(--bg-card-border);
  border-radius: var(--radius-lg);
  padding: 2rem;
  color: var(--text-main);
  overflow: hidden;
  transition: background-color 0.3s, border-color 0.3s, color 0.3s;
  box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1);
}

/* Interactive Glow Accent Layer */
.dash-card::before {
  content: "";
  position: absolute;
  inset: 0;
  background: radial-gradient(
    600px circle at var(--card-glow-x) var(--card-glow-y),
    oklch(0.70 var(--chroma) var(--hue) / var(--glow-opacity)),
    transparent 60%
  );
  pointer-events: none;
  transition: opacity 0.3s;
}

/* Header Elements */
.dash-card__header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 1.5rem;
}

.dash-card__badge {
  padding: 0.25rem 0.75rem;
  background-color: var(--color-primary-tint);
  color: var(--color-primary);
  border-radius: var(--radius-sm);
  font-size: 0.75rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.05em;
}

.dash-card__theme-btn {
  background: transparent;
  border: 1px solid var(--bg-card-border);
  color: var(--text-main);
  padding: 0.5rem 0.875rem;
  border-radius: var(--radius-sm);
  cursor: pointer;
  font-size: 0.875rem;
  transition: background-color 0.2s;
}

.dash-card__theme-btn:hover {
  background-color: var(--color-primary-tint);
}

/* Content Elements */
.dash-card__title {
  font-size: clamp(1.25rem, 2vw + 1rem, 1.75rem);
  margin-bottom: 0.75rem;
  color: var(--text-main);
}

.dash-card__text {
  font-size: 0.95rem;
  line-height: 1.6;
  color: var(--text-muted);
  margin-bottom: 1.5rem;
}

.dash-card__controls {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  background: oklch(0.50 var(--chroma) var(--hue) / 0.05);
  padding: 1rem;
  border-radius: var(--radius-sm);
}

.dash-card__controls label {
  font-size: 0.875rem;
  font-weight: 600;
}

Die Skripte verbinden die Steuerelemente mit diesen Token und schreiben stets direkt auf die Karte selbst und nicht auf :root:

/* ==========================================================================
   4. REACTIVE SCRIPT BRIDGE
   ========================================================================== */
const card = document.getElementById('dashCard');
const themeBtn = document.getElementById('themeToggle');
const hueSlider = document.getElementById('hueSlider');
const hueVal = document.getElementById('hueVal');

// 1. Mouse coordinate tracking (Targeted scope prevents full layout invalidation)
card.addEventListener('pointermove', (e) => {
  const rect = card.getBoundingClientRect();
  card.style.setProperty('--card-glow-x', `${e.clientX - rect.left}px`);
  card.style.setProperty('--card-glow-y', `${e.clientY - rect.top}px`);
});

// 2. Interactive Theme Switcher
themeBtn.addEventListener('click', () => {
  const currentTheme = card.getAttribute('data-theme');
  const nextTheme = currentTheme === 'dark' ? 'light' : 'dark';
  card.setAttribute('data-theme', nextTheme);
});

// 3. Dynamic Color Engine Adjustment
hueSlider.addEventListener('input', (e) => {
  const newHue = e.target.value;
  hueVal.textContent = newHue;

  // Update hue variable locally on component container
  card.style.setProperty('--hue', newHue);
});

Vor dem Veröffentlichen einer solchen Komponente sollten Sie zwei Aspekte im Browser überprüfen, da beide aus den oben beschriebenen Mechanismen resultieren:

  • Die Leuchtkoordinaten sind mit inherits: false registriert, werden aber in .dash-card::before verwendet. Da das Pseudo-Element sie nicht erbt, erhält es die ursprünglichen Werte von 0px, wodurch das Licht in der Ecke bleibt. Wenn sie mit inherits: true registriert werden oder unregistriert bleiben, folgt der Effekt dem Zeiger.
  • Der Slider setzt --hue auf der Karte fest, doch --color-primary und --color-primary-tint werden in :root berechnet, sodass das Abzeichen seine ursprüngliche Farbe beibehält. Nur die Regeln, die direkt var(--hue) verwenden (die Leuchwirkung sowie der Hintergrund der Steuerelemente), reagieren darauf. Durch erneute Deklaration der abgeleiteten Farbpalette in .dash-card folgt das gesamte Komponenten-Design dem Slider.

Kernpunkte

  • Verlagern Sie die Werte, die zwischen Themen und Zuständen variieren, in benannte Eigenschaften und schreiben Sie jede Komponentenregel nur einmal.
  • Deklarieren Sie Token auf dem Niveau, zu dem sie gehören – sei es :root, eine Layout-Region oder eine einzelne Komponente – und lassen Sie die Vererbung sie weiterleiten. Denken Sie daran, dass abgeleitete Token dort berechnet werden, wo sie deklariert wurden.
  • Lassen Sie JavaScript mit element.style.setProperty() Rohwerte übergeben, anstatt Style-Strings zu erstellen, und richten Sie sich auf das kleinste Element aus, das den Wert benötigt.
  • Registrieren Sie Eigenschaften mit @property sowie einer Syntax wie <color>, <angle> oder <length>, um Typüberprüfungen und nahtlose Interpolation zu ermöglichen.
  • Speichern Sie Farbkanäle in HSL oder OKLCH, um Schattierungen, Zustände und Transparenz ohne Bibliotheken zu erzeugen.
  • Halten Sie häufige Updates von :root fern, bevorzugen Sie für Bewegungen transform und opacity, und verwenden Sie inherits: false nur dann, wenn kein Nachkomme oder Pseudo-Element den Wert benötigt.
  • Kundenspezifische Eigenschaften ersetzen die Syntax von Vorkompilatoren eher nicht, sondern bilden ein anderes Modell: Styles, die vom DOM wissen, auf Interaktionen reagieren und zur Laufzeit gesteuert werden können. Auf diese Weise machen sie Design-Systeme kleiner und leichter zu ändern.