📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

~7 min de lecture

Tokens @theme Tailwind v4 : primitives vs sémantiques

Tu démarres proprement. Un bloc @theme, tes couleurs de marque dedans, et tu consommes var(--color-coral) dans tes composants. Ça marche. Puis un deuxième produit arrive avec son accent, un mode sombre se profile, un rebrand pointe le nez, et tu te retrouves à faire un chercher-remplacer de --color-coral sur quarante composants en priant pour n'en oublier aucun. Ton @theme a grossi jusqu'à devenir une liste à plat de quarante couleurs sans hiérarchie, où plus personne ne sait laquelle est "la couleur de bouton" et laquelle est juste "un rouge qui traînait".

C'est du drift, et il vient d'un seul défaut d'architecture : un seul niveau de tokens. La palette et les intentions sont mélangées dans le même sac. La correction tient en deux couches, elle est connue des design systems sérieux, et ce site (la landing Angular 21 qui sert ce blog) la fait tourner en production pour thémer trois produits depuis un seul point de bascule. On regarde comment.

Si tu es en train de migrer vers Tailwind v4, le comment-du-setup est dans l'article dédié ; ici on suppose v4 déjà en place et on parle uniquement d'architecture de tokens.

Le problème : une seule couche, du drift garanti

Regarde ce @theme naïf, celui que tout le monde écrit au début :

@theme {
  --color-coral: #f87171;
  --color-emerald: #22c55e;
  --color-yellow: #f9ed32;
}

Puis, dans un composant Angular :

/* cta-button styles */
.cta {
  background: var(--color-coral);
  border-color: var(--color-coral);
}

Le composant consomme la palette brute. Il dit "coral", pas "l'accent de la marque". Résultat : le jour où l'accent doit devenir emerald sur une autre page, ce composant ne le saura jamais tout seul. Il est câblé sur une valeur, pas sur un rôle. Pour re-thémer, tu n'as pas d'autre choix que d'aller réécrire chaque var(--color-coral) à la main. Multiplie par le nombre de composants, et tu tiens ta dette.

Le vrai problème n'est pas la couleur, c'est que --color-coral répond à la question "qu'est-ce que c'est ?" (un rouge corail) alors que le composant, lui, a besoin de répondre à "à quoi ça sert ?" (l'accent du produit courant). Tant que ces deux questions partagent le même token, tu ne peux pas changer l'une sans casser l'autre.

Les deux couches : ce que c'est vs ce que ça fait

La séparation est simple à énoncer :

  • Primitives : la palette et les échelles brutes, nommées par ce qu'elles sont. --color-coral, --color-dark-blue, --text-hero. Elles ne bougent jamais selon le contexte. C'est ton nuancier figé.
  • Sémantiques : les intentions, nommées par ce qu'elles font, et qui pointent vers une primitive. --product-accent, --product-accent-on-bg. Elles peuvent être remappées selon le contexte.

La règle d'or qui découle : un composant ne consomme que des sémantiques, jamais une primitive. Le composant parle en intentions ("mets l'accent produit"), la couche sémantique traduit l'intention en valeur, et cette traduction est le seul endroit que tu touches pour re-thémer.

Voilà les deux couches telles qu'elles vivent dans le styles.css de ce repo. D'abord les primitives, dans @theme :

@theme {
  /* Primitives : le nuancier, nommées par ce qu'elles sont */
  --color-primary-yellow: #f9ed32;   /* accent EAK */
  --color-coral: #f87171;            /* accent AAK (Tailwind red-400) */
  --color-emerald: #22c55e;          /* accent RAK (Tailwind green-500) */
  --color-dark-blue: #00204a;
  --color-white: #fbfcff;

  /* Échelle typo : même logique, des primitives de dimension */
  --text-hero: clamp(2.5rem, 5vw, 4rem);
  --text-body: 1rem;
}

Puis les sémantiques, dans :root, qui pointent vers ces primitives :

:root {
  /* Sémantiques : les intentions, nommées par ce qu'elles font */
  --product-accent: var(--color-primary-yellow);
  --product-accent-secondary: var(--color-secondary-yellow);
  --product-accent-on-bg: var(--color-dark-blue);   /* texte lisible sur l'accent */
  --product-accent-glow: 249, 237, 50;              /* RGB pour rgba() inline */
}

Un composant qui a besoin de l'accent écrit var(--product-accent). Il ne sait pas, et ne veut pas savoir, que derrière c'est du jaune.

Pourquoi les primitives dans @theme et les sémantiques dans :root

C'est le point que la plupart des tutos ratent, et il est spécifique à Tailwind v4.

Tout token déclaré dans @theme sous un namespace reconnu (--color-*, --text-*, --font-*, --shadow-*, --radius-*...) génère automatiquement l'utilitaire correspondante. --color-surface-card dans @theme, et tu obtiens gratuitement bg-surface-card dans tes templates. Attention, la règle s'arrête au namespace : un token hors liste, comme un --gradient-button maison, reste une simple variable et ne produit aucun bg-gradient-button. C'est pour les primitives de palette et d'échelle que le namespace tombe juste : une valeur stable, disponible partout en classe utilitaire, sans la réinventer à chaque carte.

Les sémantiques, elles, vivent dans :root (pas dans @theme) pour une raison précise : elles doivent pouvoir être remappées par la cascade. Et c'est là que ça devient élégant. Le remapping se fait avec le :has() qu'on a vu dans l'article sur :has() :

/* Sur une page AAK, l'accent produit devient coral. Un seul point de bascule. */
:has([data-product='aak']) {
  --product-accent: var(--color-coral);
  --product-accent-on-bg: var(--color-white);
  --product-accent-glow: 248, 113, 113;
}

:has([data-product='rak']) {
  --product-accent: var(--color-emerald);
  --product-accent-on-bg: var(--color-white);
  --product-accent-glow: 34, 197, 94;
}

La primitive --color-coral ne bouge pas, elle reste ce qu'elle est. C'est la sémantique --product-accent qu'on repointe, contextuellement, vers une autre primitive. Nuance honnête pour être exact : les utilitaires Tailwind v4 référencent la variable (ex. .bg-surface-card { background-color: var(--color-surface-card) }), donc si tu overrides une variable de @theme dans un :has(), l'utilitaire suit aussi. La séparation @theme / :root n'est donc pas une contrainte technique dure, c'est un contrat de lecture : dans @theme, ce qui est stable et doit devenir une utilitaire ; dans :root, ce qui exprime une intention et doit fléchir selon le contexte.

Le paiement : re-thémer en un seul point

Voilà pourquoi tout ça vaut le coup. Le bouton CTA cross-produit de ce repo ne consomme que des sémantiques :

.cta-product-primary {
  background: var(--gradient-button);
  color: var(--product-accent-on-bg);
  border: 1px solid rgba(var(--product-accent-glow), 0.4);
  box-shadow: 0 8px 32px rgba(var(--product-accent-glow), 0.28);
}

Aucune primitive en vue. Pas un seul --color-coral. Conséquence directe : ce bouton est jaune sur EAK, deep-red sur AAK, deep-green sur RAK, et son texte passe de dark-blue à blanc pour rester lisible sur chaque fond, sans qu'une seule ligne de ce composant ne change. Toute la bascule tient dans les trois lignes de :has() au-dessus. Tu ajoutes un quatrième produit demain ? Tu écris un bloc :has([data-product='xyz']) qui repointe les sémantiques, et l'intégralité de l'UI se rethème. Zéro chercher-remplacer.

C'est la définition d'un design system qui tient : le nombre d'endroits à toucher pour un rebrand est constant, il ne grandit pas avec le nombre de composants.

Les quatre dérives à surveiller

La séparation en deux couches n'est pas magique, elle se maintient. Voici où le drift se réintroduit :

  1. La primitive qui fuit dans un composant. Un var(--color-coral) écrit en dur dans un composant AAK au lieu de var(--product-accent). Ça marche aujourd'hui, ça casse le jour du rebrand. Bonne nouvelle : ça se lint, avec le même mécanisme declaration-property-value-disallowed-list que celui qui bannit les hex en dur (cf. l'article Stylelint) : il suffit de cibler un pattern var\(--color- au lieu d'un hex pour attraper la fuite en CI.
  2. L'alias primitif vers primitif. Ce repo a --color-emerald-deep: var(--color-success-green) : une primitive produit qui pointe une primitive d'état. C'est acceptable quand la réutilisation est intentionnelle et documentée (le vert RAK EST le vert de succès, assumé). Ça devient du drift quand tu enchaînes les alias sans savoir pourquoi. La règle : un alias primitif se justifie par un commentaire, sinon c'est une sémantique déguisée.
  3. La chaîne sémantique qui pointe une sémantique. --button-bg: var(--product-accent) qui pointe --surface-accent qui pointe --product-accent... Trois niveaux d'indirection et plus personne ne trace la valeur finale. Garde tes sémantiques à un seul saut vers une primitive.
  4. La sémantique par composant. Si tu crées --cta-button-color, --nav-link-color, --badge-color qui pointent tous vers --product-accent, tu as réinventé les primitives avec une couche de bruit. Une sémantique décrit un rôle transverse (l'accent, le texte-sur-accent), pas un composant.

Before / after

  • Before : un @theme à plat, quarante couleurs sans hiérarchie, chaque composant câblé sur var(--color-coral). Un rebrand ou un deuxième produit = un chercher-remplacer à l'aveugle sur toute la codebase, et un risque de rater une occurrence.
  • After : primitives dans @theme (le nuancier stable, qui génère les utilitaires), sémantiques dans :root (les intentions, remappées par :has()). Les composants ne parlent qu'en intentions. Re-thémer trois produits = trois blocs de bascule, et le coût d'un nouveau produit est constant.

Récap actionnable

  1. Deux couches, pas une. Primitives nommées par ce qu'elles sont (--color-coral), sémantiques nommées par ce qu'elles font (--product-accent). Les sémantiques pointent les primitives via var().
  2. Les composants ne consomment que des sémantiques. Un var(--color-*) dans un composant est un bug d'architecture, pas un détail. C'est la règle qui rend le re-theming gratuit.
  3. @theme pour ce qui doit devenir une utilitaire et rester stable ; :root pour ce qui exprime une intention et doit fléchir par contexte. En v4, un token @theme sous un namespace reconnu (--color-*, --text-*...) te donne l'utilitaire correspondante d'office.
  4. Remappe par la cascade, pas à la main. Un bloc :has([data-product]) qui repointe les sémantiques rethème toute l'UI d'un coup. Le point de bascule est unique.
  5. Lint la couche. Interdis les primitives --color-* hors de la couche sémantique avec Stylelint. La discipline des deux couches ne survit pas sans un garde-fou automatique.

Un design system ne se juge pas au nombre de tokens qu'il déclare, mais au nombre d'endroits qu'il faut toucher pour changer d'avis. Deux couches bien tenues, et ce nombre reste à un.

📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

AngularKit

Suite d'outils pour développeurs Angular francophones. Apprends, modernise tes réflexes, audite ta codebase.

Produits

Contact

Légal

© 2026 AngularKit. Tous droits réservés.