Accueil / Articles

Propriétés personnalisées CSS en temps de exécution : thématisation, ponts JavaScript et @property

Découvrez en quoi les propriétés personnalisées CSS diffèrent des variables Sass, et apprenez à les utiliser pour la personnalisation des thèmes, les effets gérés par JavaScript, le formatage fluide du texte, les variantes de composants et les animations typées.

Éditeur : REACTAPP.TOP17 min de lecture3986 mots

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

De nombreux projets utilisent les propriétés personnalisées CSS exactement comme des variables Sass : une liste de couleurs déclarée une seule fois en :root et jamais modifiée par la suite. Cela fonctionne, mais cela ignore la caractéristique qui les distingue. Les propriétés personnalisées sont des valeurs dynamiques dans le navigateur qui se transmettent à travers le DOM, réagissent aux requêtes médias et aux changements d’attributs, et peuvent être mises à jour depuis JavaScript à tout moment. Ce guide montre ce que ce comportement en temps de exécution permet concrètement, allant du thématisation et des composants paramétrés à l’animation typée avec @property, et souligne les mécanismes ainsi que les pièges qui entravent les équipes.

Constantes en temps de compilation versus valeurs dynamiques

Des préprocesseurs tels que Sass, Less et Stylus ont façonné le stylisme des interfaces utilisateur pendant des années grâce au nesting, aux mixins et aux variables. Lorsque des propriétés personnalisées natives sont apparues dans les navigateurs, une réaction courante a été de penser qu’elles ne ajoutaient rien que $primary-color ne fournissait déjà pas. Cette supposition est à l’origine de leur sous-utilisation. La différence réside dans le moment où la valeur est disponible :

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

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

Ce qu’une variable de préprocesseur devient

Une variable Sass est résolue avant même que le CSS n’arrive dans le navigateur. Dans le SCSS suivant, une deuxième valeur est assignée à l’intérieur du bloc modificateur dark :

$brand-color: #3b82f6;

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

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

Le compilateur remplace la valeur littérale partout où la variable est utilisée, de sorte que le fichier CSS généré se résume simplement à ceci :

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

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

Après compilation, le nom $brand-color n’existe plus. Le navigateur ne peut pas savoir que les deux couleurs proviennent d’une même variable, et il ne peut pas modifier cette valeur en réponse à un clic, à un changement de taille d’affichage ou à un état de survol. Chaque variation doit être générée à l’avance sous forme de CSS supplémentaire.

Ce qu’une propriété personnalisée conserve

La version native maintient le nom actif dans le navigateur :

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

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

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

Ici, --brand-color fait partie du style calculé de chaque élément. Lorsqu’un .card reçoit l’attribut data-theme="dark", le navigateur recalcule var(--brand-color) pour ce card et ses descendants. La règle associée à .card n’est pas dupliquée ; il suffit de modifier la valeur pour une seule branche de l’arbre DOM. En bref : les variables Sass sont remplacées lors de la compilation et disparaissent à l’exécution, tandis que les propriétés personnalisées sont résolues par le navigateur pour chaque élément, participent à la cascade et peuvent changer après chargement de la page.

Les mécanismes à maîtriser

Quelques règles concernant la syntaxe, le champ d’application et la gestion des erreurs expliquent la plupart des comportements surprenants rencontrés.

Dénomination et lecture des valeurs

Le nom d’une propriété personnalisée doit commencer par deux tirets, et les noms sont sensibles à la casse ; il s’agit donc de deux propriétés distinctes :

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

var() :

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

Le champ d’application suit la cascade

Les propriétés personnalisées respectent la cascade et l’héritage normaux. En les déclarant sur :root, qui correspond à l’élément <html>, elles deviennent accessibles partout. En les déclarant sur un autre sélecteur, on ne les remplace que pour cet élément et ses descendants :

/* 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 */
}

Les deux widgets utilisent la même règle, mais celui situé à l’intérieur de .sidebar obtient un espacement plus petit, car la déclaration la plus proche l’emporte. C’est ce qui rend les surcharges ciblées si efficaces : on modifie une valeur, pas une règle.

Valeurs par défaut

var() accepte un deuxième argument qui est utilisé lorsque la propriété référencée n’a pas de valeur. Les valeurs par défaut peuvent elles-mêmes contenir des appels à var(), ce qui permet de les enchaîner :

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

Soyez précis quant au moment où un fallback s’applique. Il est utilisé uniquement lorsque la propriété personnalisée n’est pas définie pour cet élément (ni héritée d’un ancêtre) ou est explicitement mise à initial. Si la propriété est définie mais contient une valeur qui n’a aucun sens pour le destinataire, comme une longueur là où une couleur est attendue, le fallback est ignoré. Notez que unset n’est pas une méthode fiable pour le déclencher, car les propriétés personnalisées sont héritées par défaut ; ainsi unset se comporte comme inherit et reprend la valeur du parent s’il en existe une.

Invalidité au moment de la valeur calculée

var() ne peuvent pas être vérifiées pendant la parsing, car la valeur substituée reste inconnue jusqu’à l’exécution de la cascade :

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

Le parseur accepte la deuxième ligne color et elle remplace color: red. Ce n’est qu’au moment du calcul des valeurs que le navigateur découvre que 12px n’est pas une couleur. À ce stade, la déclaration précédente a déjà perdu son effet, de sorte que la propriété se comporte comme si elle était unset : les propriétés héritées telles que color prennent la valeur du parent, tandis que celles qui ne sont pas héritées reviennent à leur valeur initiale (par exemple, transparent pour background-color). Cette défaillance silencieuse fait partie des problèmes que @property, abordé plus tard, est conçu pour résoudre.

Thématisation sans règles dupliquées

Une implémentation classique du mode sombre duplique chaque règle de thématisation :

/* 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);
}

Avec quelques propriétés, cela reste gérable. Avec des dizaines de composants, chacun comportant de nombreuses déclarations thématiques, chaque thème multiplie les sélecteurs qu’il faut synchroniser.

Les tokens rendent les composants indépendants du thème

Lorsque les décisions de conception sont stockées dans des propriétés personnalisées, les règles des composants n’ont plus besoin de savoir quel thème est actif :

/* 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));
}

La règle .card existe une seule fois. Ajouter un troisième thème, comme [data-theme="high-contrast"], signifie fournir quatre valeurs de tokens au lieu de rédéclarer des règles pour chaque composant.

Dérivation d’une palette à partir des canaux de couleur

Vous pouvez aller plus loin en stockant les différents canaux d’une couleur, par exemple en OKLCH, et en calculant à partir d’eux toutes les variations possibles :

: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);
}

Les variantes hover, active, tint et translucent sont toutes des expressions basées sur trois nombres. Modifier --brand-h dans :root, que ce soit depuis JavaScript ou en fonction des préférences de l’utilisateur, permet de récolorer la couleur de base, ses états, le tint ainsi que les superpositions de manière cohérente, sans avoir recours à une bibliothèque de manipulation des couleurs.

Une subtilité concerne chaque token dérivé : une instruction var() à l’intérieur d’une propriété personnalisée est évaluée sur l’élément où cette propriété est déclarée, et les éléments descendants héritent de la valeur résultante. Comme --brand-color et ses similaires sont déclarés dans :root, modifier --brand-h plus en profondeur dans l’arborescence ne les recalcule pas à cet endroit. Il faut soit modifier la valeur dans :root, soit redéclarer les tokens dérivés dans le scope où vous modifiez cette valeur.

Gérer le CSS depuis JavaScript

Les propriétés personnalisées constituent une interface claire entre les scripts et les styles. Au lieu d’écrire des chaînes de style en ligne ou de gérer des combinaisons de classes, JavaScript peut transmettre des valeurs brutes, telles que les coordonnées du pointeur ou l’état de l’application, à CSS, permettant ainsi au feuille de style de décider quoi en faire.

Soulignement qui suit le curseur

Commencez par un élément card :

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

Le feuille de style définit des coordonnées par défaut et les utilise pour positionner un dégradé radial dans un pseudo-élément :

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

Le script ne fait que signaler l’emplacement du pointeur par rapport à la carte :

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`);
});

Cette répartition des tâches est l’avantage : JavaScript écrit deux nombres sur un élément sans jamais toucher au dégradé lui-même. Cependant, ce n’est pas gratuit. Chaque mise à jour déclenche un rec calcul des styles de la carte, et comme le dégradé change, l’overlay est recolori. Ce coût est faible pour un seul élément, d’où le fait que l’effet fonctionne généralement sans problème, mais il ne s’agit pas d’un « zero reflow » se produisant en dehors du thread principal.

Une barre de progression de défilement

On peut appliquer la même idée en transmettant la position de défilement au CSS sous forme d’une fraction sans unité comprise entre 0 et 1 :

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

La barre l’utilise via transform, ce qui évite les opérations de mise en page car l’échelle ne modifie pas la taille des autres éléments de la page :

/* 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;
}

Deux améliorations méritent d’être prises en compte. Le script écrit dans document.documentElement, ce qui correspond précisément à la mise à jour globale et fréquente contre laquelle met en garde la section sur les performances ; définir la valeur directement sur la barre limite le rec calcul à un seul élément. De plus, dans les navigateurs prenant en charge les animations déclenchées par le défilement CSS, une telle barre de progression peut être créée à l’aide de animation-timeline: scroll() sans aucun JavaScript.

Typographie fluide et ratios de mise à l’échelle

La typographie basée sur des points de rupture passe entre des tailles fixes à des largeurs telles que 768px et 1024px. En combinant des propriétés personnalisées avec clamp() et calc(), les tailles peuvent plutôt varier continuellement entre une valeur minimale et une valeur maximale.

Calcul paramétré de clamp()

L’idée est de décrire l’échelle à l’aide de simples nombres (plage de vue et plage de taille de police) et d’en déduire la pente et l’ordonnée à l’origine d’une fonction linéaire entre eux :

: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);
}

Considérez ce fragment comme un point de départ et non comme du code fonctionnel. La substitution des propriétés personnalisées se fait de manière textuelle ; ainsi, lorsque --y-axis-intersection est inséré dans calc(var(--y-axis-intersection) * 1px + ...), le * 1px s’applique uniquement au dernier terme de cette expression. Le premier terme, dépourvu d’unité, est alors ajouté à une longueur, ce qui rend toute la fonction clamp() invalide. En enveloppant les expressions intermédiaires entre parenthèses, par exemple --y-axis-intersection: (-1 * var(--fluid-min-width) * var(--slope) + var(--font-min-size));, ou à l’intérieur de calc(), on conserve le regroupement souhaité.

Tailles de titres harmoniques à partir d’un rapport

Avec une taille de base définie, un seul rapport peut générer toute la gamme des titres :

: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); }

Chaque étape multiplie la précédente, ce qui permet de maintenir des proportions constantes entre les niveaux. Modifier le rapport au sein d’une requête média recalcule tous les niveaux en même temps :

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

Cela fonctionne parce que les étapes et le rapport sont déclarés sur le même élément, :root, de sorte que la modification est visible lors du calcul des étapes.

Composants avec des paramètres plutôt que des classes de variantes

Les classes de variantes dans les méthodologies BEM ou similaires ont tendance à s’accumuler : chaque taille, forme et couleur devient un nouveau bloc de déclarations qui répète partiellement la base.

/* 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; }

Propriétés personnalisées en tant qu’API d’un composant

Une approche plus robuste considère les propriétés personnalisées comme la configuration du composant. La règle de base lit chaque valeur ajustable provenant d’une propriété disposant d’une valeur par défaut, et les modificateurs ne modifient que les paramètres :

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

Des modificateurs tels que .btn--lg ne répètent pas display, border-radius, cursor ou transition. Ils ne définissent que de nouvelles valeurs pour ces paramètres. Le fichier de style reste compact, les modificateurs s’assemblent naturellement (btn--lg btn--pill btn--danger fonctionne simplement), et lorsque une variante ne semble pas correcte, on sait que cela ne peut venir que de l’un des quelques modificateurs possibles.

Propriétés typées et animation avec @property

Les propriétés personnalisées non enregistrées présentent une limitation bien connue : elles ne peuvent pas être interpolées lors de transitions ou de keyframes. Le navigateur traite leurs valeurs comme des séquences de tokens non typées, ce qui l’empêche d’animer de 0deg à 360deg ou de red à blue ; la valeur change simplement à un moment donné pendant l’animation.

L’at-rule @property enregistre une propriété personnalisée avec une syntaxe, une valeur initiale et des paramètres d’héritage. Il est pris en charge dans les versions actuelles des principaux navigateurs, mais vérifiez vos cibles de compatibilité avant de vous y fier.

Enregistrement des propriétés typées

/* 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;
}

Cet enregistrement résout également le problème de faute silencieuse mentionné précédemment : une valeur qui ne correspond pas à la syntaxe déclarée est rejetée, et la propriété reprend sa valeur initiale enregistrée au lieu d’une valeur héritée inattendue.

Transitions sur les gradients

Lorsque le navigateur sait que --gradient-angle représente un angle et que --accent-color représente une couleur, il peut les interpoler, ce qui rend possibles des transitions de gradient :

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

Animation continue par keyframes

Cette même mise en correspondance permet d’utiliser des keyframes pour faire tourner un gradient indéfiniment :

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

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

N’oubliez pas qu’animer une propriété enregistrée utilisée dans un gradient fait toujours redessiner l’élément à chaque frame sur le thread principal ; ce n’est pas identique à une animation transform ou opacity qui ne concerne que le composant.

Prestations : où les mises à jour nuisent

Les propriétés personnalisées ne sont pas lentes en soi. Le coût dépend de la partie de l’arborescence que la mise à jour rend invalide.

Jusqu’où s’étend une mise à jour

Appeler element.style.setProperty('--name', value) entraîne une invalidation des styles pour cet élément ainsi que pour tout ce qui pourrait en hériter :

+-------------------------------------------------------------------------+
|                  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)                        |
+-------------------------------------------------------------------------+
  • Changer une propriété sur :root peut forcer le navigateur à recalculer les styles pour chaque élément qui en dépend quelque part dans le document. Faire cela à chaque événement scroll ou mousemove sur une page volumineuse peut entraîner des retards visuels.
  • Définir la propriété sur un élément spécifique, comme la carte sur laquelle on passe le curseur, limite le rec calcul aux seuls éléments concernés.

Directives pratiques

Tout d’abord, envoyez les mises à jour fréquentes, telles que les positions du curseur ou l’avancement de la balise de défilement, vers le composant qui les utilise, et non vers :root.

Deuxièmement, pour les valeurs dont aucun descendant n’a besoin, enregistrez-les avec inherits: false, ce qui limite la propagation des changements pendant l’animation :

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

Il y a un point important à retenir : les pseudo-éléments héritent de leur élément d’origine, donc une propriété enregistrée mais non héritée définie sur un élément n’est pas visible à l’intérieur de ::before ou ::after de cet élément. Si un pseudo-élément lit cette valeur, conservez la propriété héritée ou appliquez l’effet directement sur l’élément lui-même.

Troisièmement, lorsque une valeur influence le mouvement, utilisez-la via transform ou opacity afin que le changement évite le recalcul du layout et, si possible, la rédessinée :

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

Assemblage : une carte de tableau de bord thématique

Le widget suivant combine plusieurs techniques : deux thèmes, un effet de brillance qui suit le curseur, un contrôle des teintes permettant de changer la couleur du composant, ainsi que des tailles de titres dynamiques. La structure est constituée d’un élément sémantique <article> comprenant un en-tête, un bouton de mise à jour et un champ de saisie numérique :

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

Le fichier de style est organisé en trois niveaux : les propriétés enregistrées et les tokens globaux, les plages de thème sur la carte, ainsi que les règles du composant qui ne lisent que ces tokens :

/* ==========================================================================
   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;
}

Le script relie les contrôles à ces tokens, en écrivant toujours directement sur la carte elle-même et non sur :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);
});

Au préalable de déployer un composant de ce type, vérifiez deux éléments dans le navigateur, car ils découlent tous deux des mécanismes décrits ci-dessus :

  • Les coordonnées de l’éclat sont enregistrées avec inherits: false mais utilisées dans .dash-card::before. Comme le pseudo-élément ne les hérite pas, il conserve les valeurs initiales de 0px et la lumière reste dans le coin. En les enregistrant avec inherits: true ou en ne les enregistrant pas du tout, l’effet peut suivre le pointeur.
  • Le curseur définit la valeur de --hue sur la carte, mais --color-primary et --color-primary-tint sont calculés à niveau :root, ce qui fait que l’icône conserve sa couleur d’origine. Seules les règles qui utilisent directement var(--hue) (l’éclat et l’arrière-plan des contrôles) réagissent. En redéclarant la palette dérivée sur .dash-card, l’ensemble du composant suit le curseur.

Points clés

  • Transférez les valeurs qui varient selon les thèmes et les états dans des propriétés personnalisées, et écrivez chaque règle de composant une seule fois.
  • Déclarez les tokens au niveau où ils appartiennent, que ce soit :root, une région de mise en page ou un composant unique, et laissez l’héritage les transmettre vers le bas. N’oubliez pas que les tokens dérivés sont calculés là où ils ont été déclarés.
  • Laissez JavaScript transmettre des valeurs brutes à l’aide de element.style.setProperty() au lieu de construire des chaînes de style, et ciblez l’élément le plus petit qui a besoin de cette valeur.
  • Enregistrez les propriétés avec @property et une syntaxe telle que <color>, <angle> ou <length> pour bénéficier de vérifications de type et d’une interpolation fluide.
  • Stockez les canaux de couleur en HSL ou OKLCH afin de dériver des teintes, des états et de la transparence sans utiliser de bibliothèque.
  • Évitez les mises à jour fréquentes en dehors de :root ; préférez transform et opacity pour les animations, et n’utilisez inherits: false que lorsque aucun descendant ou pseudo-élément n’a besoin de cette valeur.
  • Les propriétés personnalisées ne remplacent pas vraiment la syntaxe des préprocesseurs, mais constituent plutôt un modèle différent : des styles conscients du DOM, capables de réagir aux interactions et pouvant être gérés en temps de exécution. Utilisées de cette manière, elles permettent de réduire la taille des systèmes de conception et d’en faciliter la modification.