Pourquoi les variables CSS classiques montrent vite leurs limites
Les variables CSS, ou custom properties, sont devenues indispensables pour structurer les couleurs, espacements, tailles, thèmes et variantes d’interface. Elles sont particulièrement utiles lorsqu’on construit un design system avec des design tokens en CSS et TypeScript, ou lorsqu’on veut mieux organiser ses priorités de style avec les CSS cascade layers.
Mais une variable CSS classique reste très permissive. Le navigateur ne sait pas vraiment si --angle représente un angle, une couleur, une longueur ou une chaîne libre. Il conserve simplement une valeur textuelle qui sera interprétée au moment où elle est utilisée.
.card {
--rotation: 12deg;
transform: rotate(var(--rotation));
}
Ce comportement est puissant, mais il pose plusieurs problèmes :
- les valeurs invalides sont souvent détectées tardivement ;
- les transitions ne fonctionnent pas toujours comme prévu ;
- le navigateur ne peut pas interpoler correctement certains changements ;
- il est difficile d’exprimer une valeur initiale robuste ;
- la maintenance devient fragile dans les composants complexes.
La règle CSS @property permet de déclarer une custom property avec une syntaxe attendue, une valeur initiale et une règle d’héritage. En pratique, elle permet de transformer certaines variables CSS en propriétés plus proches des propriétés natives du langage.
Déclarer une propriété CSS typée
Une déclaration @property se place généralement à la racine de votre feuille CSS, avant l’usage de la propriété.
@property --card-angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
.card {
--card-angle: 0deg;
transform: rotate(var(--card-angle));
transition: --card-angle 300ms ease;
}
.card:hover {
--card-angle: 4deg;
}
Ici, --card-angle est explicitement déclarée comme un angle. Le navigateur sait donc que 0deg et 4deg appartiennent au même type de valeur et peut interpoler proprement entre les deux pendant la transition.
Sans @property, une custom property est souvent considérée comme une valeur discrète : elle passe brutalement d’un état à l’autre, car le moteur CSS ne sait pas comment calculer les étapes intermédiaires.
Les trois champs sont importants :
syntax
syntax indique le type de valeur autorisé. Quelques exemples courants :
@property --progress {
syntax: "<number>";
inherits: false;
initial-value: 0;
}
@property --brand-color {
syntax: "<color>";
inherits: true;
initial-value: #3b82f6;
}
@property --panel-size {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
On peut utiliser des syntaxes comme <number>, <length>, <percentage>, <length-percentage>, <color>, <angle>, <time>, <resolution> ou encore certaines combinaisons. La documentation MDN sur [@property](https://developer.mozilla.org/en-US/docs/Web/CSS/@property) reste une bonne référence pour vérifier les syntaxes prises en charge.
inherits
inherits contrôle l’héritage. Une variable CSS classique hérite par défaut. Avec @property, vous pouvez choisir explicitement le comportement.
@property --surface-opacity {
syntax: "<number>";
inherits: false;
initial-value: 1;
}
Pour une valeur locale à un composant, inherits: false évite des surprises. Pour un token de thème comme une couleur de marque, inherits: true peut être pertinent.
initial-value
initial-value fournit une valeur de secours valide. Elle doit correspondre à la syntaxe déclarée.
@property --radius {
syntax: "<length>";
inherits: false;
initial-value: 8px;
}
Cette valeur initiale rend le comportement plus prévisible, notamment lorsque la propriété n’est pas encore définie sur un élément.
Créer une jauge animée sans JavaScript
Prenons un cas concret : une barre de progression circulaire. On veut animer une valeur numérique, puis l’utiliser dans un conic-gradient.
@property --progress {
syntax: "<percentage>";
inherits: false;
initial-value: 0%;
}
.progress-ring {
--progress: 0%;
width: 160px;
aspect-ratio: 1;
border-radius: 50%;
background: conic-gradient(
#2563eb var(--progress),
#e5e7eb 0
);
transition: --progress 800ms ease;
}
.progress-ring[data-value="75"] {
--progress: 75%;
}
La custom property --progress est typée comme un pourcentage. Le navigateur peut donc animer le passage de 0% à 75%. C’est particulièrement utile pour les interfaces de tableaux de bord, les visualisations simples ou les micro-interactions.
Pour des animations liées au défilement, cette approche peut aussi compléter les scroll-driven animations CSS. On garde alors une logique déclarative, lisible et performante.
Utiliser @property avec des composants
Dans une architecture frontend moderne, @property devient intéressante lorsqu’elle sert à clarifier le contrat stylistique d’un composant.
@property --button-scale {
syntax: "<number>";
inherits: false;
initial-value: 1;
}
.button {
--button-scale: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
border: 0;
border-radius: 999px;
font: inherit;
cursor: pointer;
transform: scale(var(--button-scale));
transition:
--button-scale 160ms ease,
background-color 160ms ease;
}
.button:hover {
--button-scale: 1.03;
}
.button:active {
--button-scale: 0.98;
}
Cette écriture sépare clairement l’intention de l’effet final. La variable --button-scale décrit l’état du composant, tandis que transform décrit le rendu.
Cela peut paraître subtil, mais cette séparation devient précieuse dans les composants complexes : cartes interactives, panneaux flottants, menus, popovers, transitions de pages ou interfaces immersives. Si vous travaillez avec des menus contextuels, vous pouvez par exemple combiner cette approche avec la Popover API ou avec CSS Anchor Positioning.
Exemple avec React et TypeScript
@property reste une fonctionnalité CSS. React n’est pas nécessaire. Mais dans une application React, on peut exposer des valeurs dynamiques via des custom properties inline.
type GaugeProps = {
value: number;
label: string;
};
export function Gauge({ value, label }: GaugeProps) {
const safeValue = Math.max(0, Math.min(100, value));
return (
<div
className="gauge"
style={{ "--gauge-value": `${safeValue}%` } as React.CSSProperties}
aria-label={`${label} : ${safeValue}%`}
role="img"
>
<span>{safeValue}%</span>
</div>
);
}
@property --gauge-value {
syntax: "<percentage>";
inherits: false;
initial-value: 0%;
}
.gauge {
width: 8rem;
aspect-ratio: 1;
border-radius: 50%;
display: grid;
place-items: center;
background: conic-gradient(#16a34a var(--gauge-value), #dcfce7 0);
transition: --gauge-value 500ms ease;
}
.gauge span {
display: grid;
place-items: center;
width: 70%;
aspect-ratio: 1;
border-radius: 50%;
background: white;
font-weight: 700;
}
Le typage TypeScript protège ici la donnée côté composant. Le typage CSS protège l’interpolation et le comportement visuel côté navigateur. Les deux niveaux ne remplacent pas l’un l’autre : ils se complètent.
Accessibilité et préférences utilisateur
Comme toute animation, une transition basée sur @property doit respecter les préférences utilisateur. La règle prefers-reduced-motion reste indispensable.
@media (prefers-reduced-motion: reduce) {
.card,
.button,
.gauge,
.progress-ring {
transition-duration: 0.01ms;
}
}
Il ne faut pas non plus transmettre une information uniquement par une animation ou une couleur. Pour une jauge, un texte lisible ou une alternative ARIA peut être nécessaire. Pour un formulaire, une erreur doit rester compréhensible sans mouvement, comme dans une approche de formulaires accessibles.
Bonnes pratiques d’architecture
@property ne doit pas être utilisé pour toutes les variables CSS. Il est particulièrement pertinent lorsque la variable doit être animée, interpolée ou sécurisée par une valeur initiale explicite.
Pour une base CSS maintenable, je recommande ces règles :
- typer les variables qui participent à des transitions ;
- éviter les noms trop génériques comme
--valueou--xhors prototypes ; - préfixer les variables par composant ou domaine, par exemple
--card-angle,--gauge-value,--popover-offset; - garder les tokens globaux simples lorsque le typage n’apporte rien ;
- documenter les propriétés publiques d’un composant si elles sont destinées à être surchargées.
Un exemple de convention :
@layer properties {
@property --modal-backdrop-opacity {
syntax: "<number>";
inherits: false;
initial-value: 0;
}
}
@layer components {
.modal-backdrop {
opacity: var(--modal-backdrop-opacity);
transition: --modal-backdrop-opacity 200ms ease;
}
.modal-backdrop[data-open="true"] {
--modal-backdrop-opacity: 1;
}
}
Le placement dans une cascade layer dédiée peut rendre les déclarations plus faciles à retrouver, surtout dans un projet long terme.
Les limites à connaître
@property ne transforme pas CSS en langage de programmation typé. Il ne valide pas toute votre architecture, ne remplace pas les conventions d’équipe et ne rend pas automatiquement une interface accessible.
Il faut aussi garder en tête que toutes les syntaxes ne sont pas équivalentes. Certaines valeurs complexes sont plus difficiles à typer proprement. Pour des cas très spécifiques, une variable CSS classique peut rester plus simple.
Enfin, la lisibilité prime. Si une déclaration @property ajoute beaucoup de cérémonie pour une variable utilisée une seule fois sans transition, elle n’apporte probablement pas grand-chose.
Conclusion
CSS @property est une fonctionnalité discrète mais très utile pour les interfaces modernes. Elle permet de déclarer des custom properties typées, animables et dotées de valeurs initiales fiables. Son intérêt est particulièrement net pour les animations, les composants interactifs, les jauges, les transitions de thème et les architectures CSS structurées.
La bonne approche consiste à l’utiliser là où elle clarifie le contrat d’un composant : une valeur numérique qui progresse, un angle qui tourne, une couleur qui s’interpole, une taille qui s’anime. En combinant @property avec des design tokens, des cascade layers et des règles d’accessibilité solides, on obtient un CSS à la fois plus expressif, plus robuste et plus facile à maintenir.