Blog

Migration Tailwind CSS v3 vers v4 : nouveautés et pièges à éviter

Retour d'expérience sur la migration Tailwind CSS v3 vers v4 : configuration CSS-first, pièges silencieux, theming multi-projets et gains concrets.

Mathieu Ducrot Mathieu Ducrot
|
|
20 min de lecture
| Tech
Résumez cette page avec votre IA préférée :
Migration Tailwind CSS v3 vers v4 : retour d'expérience

Nous avons migré notre plateforme Symfony/Vue 3 de Tailwind CSS v3.2 vers v4 : un design system centralisé dans un bundle (80+ composants Vue) consommé par plusieurs projets clients. Cet article restitue les changements factuels constatés, puis la nouvelle organisation du thème que la v4 permet entre un bundle central et ses projets.

Sommaire

  1. Pourquoi faire cette migration ?
  2. Comprendre la nouvelle configuration en CSS
  3. Les impacts CSS constatés sur nos projets
  4. Déclarer les variables de thème : bundle central, config projet
  5. Surcharger le thème : par interface, par zone, par composant
  6. Responsive et media queries
  7. Profiter des nouveautés depuis la migration
  8. Conclusion
  9. Liens utiles

1. Pourquoi faire cette migration ?

Tailwind CSS v4 est devenu le standard front de l’écosystème : c’est la version sur laquelle s’alignent les templates et composants fournis par Tailwind UI, NuxtUI, PrimeVue et les autres librairies basées sur Tailwind. Rester en v3, c’est s’éloigner progressivement de ces ressources et ralentir l’intégration de tout template ou composant communautaire.

Au-delà de la compatibilité écosystème, la v4 apporte des gains concrets :

  • Future version LTS : la v4 est la branche qui recevra le support long terme ; la v3 n’évoluera plus.
  • Simplification de la configuration : le tailwind.config.js disparaît au profit d’un fichier CSS unique (@theme, @source, @plugin). Moins de fichiers à maintenir, moins de synchronisation entre projets.
  • Moins de dépendances de preprocessing : PostCSS, Sass, Autoprefixer et Babel ne sont plus nécessaires, la v4 intègre nativement le traitement des imports, le nesting, le préfixage et la transpilation. Autoprefixer disparaît précisément parce que Lightning CSS prend le relais en interne : préfixage vendeur automatique et transformations de syntaxe moderne (nesting, custom media queries, propriétés logiques, espaces colorimétriques étendus). C’est autant de surface de maintenance et de CVE potentielles en moins.

Un point de vigilance avant de se lancer : la v4 s’appuie sur des fonctionnalités CSS récentes (@property, cascade layers natifs, color-mix()) et cible officiellement, selon la documentation de compatibilité, Chrome 111 (sorti en mars 2023), Safari 16.4 (sorti en mars 2023) et Firefox 128 (sorti en juillet 2024), sans dégradation gracieuse vers les moteurs plus anciens. Edge n’est pas cité par la documentation, mais partage le moteur Chromium de Chrome : Edge 111 est sorti la même semaine, en mars 2023, et couvre donc le même socle de fonctionnalités.

Cette base minimale ne couvre que le cœur du framework. Pour les fonctionnalités les plus récentes qu’elle expose en variant optionnel, comme starting: (basé sur @starting-style, pour des animations d’apparition sans JS), la barre monte : caniuse situe le support à Chrome 117, Edge 117, Firefox 129, Safari 17.5 et Opera 103, tous plus récents que le socle Chrome/Safari/Firefox annoncé par Tailwind. La documentation officielle le précise elle-même : vérifiez sur caniuse la fonctionnalité précise que vous comptez utiliser avant de l’adopter, la base 111/16.4/128 garantit le fonctionnement du moteur, pas celui de chaque variant bleeding-edge pris individuellement.

Sur nos projets Symfony/Vue destinés à des utilisateurs professionnels sur navigateurs à jour, ça n’a jamais posé de problème, mais c’est un prérequis à vérifier avant toute migration si une partie de votre parc utilisateur tourne encore sur un navigateur d’entreprise ancien.

C’est aussi ce qui explique notre calendrier : Tailwind CSS v4 a été annoncé officiellement en janvier 2025, mais nous n’avons officialisé la migration sur nos projets qu’en 2026. Le temps que le socle de navigateurs requis soit réellement installé chez nos clients (renouvellement du parc, mises à jour d’entreprise souvent plus lentes que le grand public) et que l’écosystème de plugins se stabilise autour de la config CSS-first, mieux valait laisser une version tourner en conditions réelles ailleurs avant de l’appliquer à un design system consommé par plusieurs projets clients.

2. Comprendre la nouvelle configuration en CSS

Le changement structurant de la v4 : plus de tailwind.config.js.

La configuration (thème, sources à scanner, safelist, plugins) vit directement dans le fichier CSS d’entrée.

Avant (v3) : deux fichiers JS + des directives

// tailwind.config.js
module.exports = {
  content: [
    'templates/**/*.html.twig',
    'vendor/smartbooster/**/*.vue',
    'assets/admin/scripts/**/*.vue',
  ],
  theme: {
    extend: {
      colors: {
        primary: {
          lighter: 'var(--primary-lighter)',
          DEFAULT: 'var(--primary-default)',
          dark: 'var(--primary-dark)',
          // ...
        },
      },
    },
  },
  safelist: [
    'grid-cols-2', 'grid-cols-3', /* ... ~X entrées ... */
  ],
  plugins: [
    require('@tailwindcss/forms'),
    require('@tailwindcss/typography'),
  ],
}
// main.scss
@tailwind base;
@tailwind components;
@tailwind utilities;

@import "base";
@import "../../../vendor/smartbooster/platform-core-bundle/assets/styles/base";

Après (v4) : un seul fichier CSS

Les trois directives @tailwind sont remplacées par un import global, et chaque entrée du config JS a son équivalent CSS : content@source, safelist@source inline(), plugins@plugin, theme@theme.

/* main.css */
@import "../../../vendor/smartbooster/platform-core-bundle/assets/styles/base.css";
@import "../../../vendor/smartbooster/platform-core-bundle/assets/styles/theme.css";
@import "base.css";
@import "tailwindcss";
@plugin "@tailwindcss/typography";

/* Sources explicites (vendor/ est gitignoré, donc hors détection automatique) */
@source "../../../assets";
@source "../../../templates";
@source "../../../vendor/smartbooster/platform-core-bundle/assets/scripts";
@source "../../../vendor/smartbooster/platform-core-bundle/templates";

/* Équivalent de la safelist v3, avec expansion d'accolades */
@source inline("grid-cols-{2,3,4,5,6,7,8,9,10,11,12,13}");
@source inline("{text,bg}-{success,warning,danger,info}{,-light,-dark}");
/* ... */

Détail des subtilités :

  • @import "tailwindcss" se déplie en couches natives (@layer theme, base, components, utilities). La priorité des styles est fixée par la déclaration des layers, pas par l’ordre physique des imports : on peut importer ses styles avant ou après Tailwind sans changer le rendu.

  • La détection des sources est automatique (elle respecte le .gitignore) ; @source ne sert qu’à ajouter des chemins hors radar, comme un bundle dans vendor/. Cibler des répertoires précis : un glob large sur vendor/ fait scanner des milliers de fichiers inutiles, c’est pourquoi il est préférable de cibler uniquement notre librairie.

  • La safelist v3 (tableau JS) devient @source inline() avec expansion d’accolades, beaucoup plus compacte.

  • Les utilitaires custom définis avec @layer utilities doivent migrer vers @utility pour bénéficier des variants (hover:, lg:, etc.) : @utility tab-4 { tab-size: 4; }. Les classes de composants (@layer components) restent inchangées.

  • @utility accepte aussi un nom en * pour générer une famille entière de classes à la volée, sans les déclarer une par une. --value() capture la valeur passée dans la classe :

    @utility stack-* {
      display: flex;
      flex-direction: column;
      gap: calc(var(--spacing) * --value(integer));
    }

    stack-4 génère alors gap: calc(var(--spacing) * 4) à la demande, comme tab-4 mais sans avoir à écrire stack-1, stack-2, stack-4, etc. à l’avance.

  • Techniquement PostCSS est toujours supporté via @tailwindcss/postcss (l’ancien plugin tailwindcss: {} est remplacé, pas PostCSS lui-même). Si le projet n’utilisait PostCSS que pour Tailwind, postcss.config.js et autoprefixer peuvent être supprimés ; sinon, il faut juste mettre à jour le plugin.

  • La v4 est pensée CSS natif et s’accommode mal de Sass : la migration a été l’occasion de convertir nos .scss en .css (nesting natif, var() à la place des variables Sass).

3. Les impacts CSS constatés sur nos projets

Le guide de migration officiel liste tous les changements. Voici ceux qui ont réellement impacté notre codebase, constatés lors de recette visuelle.

Couleur de bordure par défaut : currentColor

En v3, border, divide-* et ring sans couleur explicite héritaient de gray-200.

En v4, la valeur par défaut est currentColor : toute bordure non colorée devient de la couleur du texte.

Exemple de correction :

- <table class="listing min-w-full divide-y border-t">
+ <table class="listing min-w-full divide-y divide-gray-200 border-t border-gray-200">

ring : largeur et couleur par défaut changées

ring sans taille ni couleur change de comportement en v4, un changement silencieux :

v3v4
Largeur3px1px (ring = ring-1)
Couleurblue-500currentColor
- <button class="ring ring-blue-500">
+ <button class="ring-3 ring-blue-500">

Un ring sans couleur explicite qui rendait bleu en v3 devient transparent ou prend la couleur du texte en v4.

L’échelle des ombres, arrondis, et flous est décalée

La v4 renomme les échelles pour introduire des tailles plus petites : l’ancien shadow-sm s’appelle désormais shadow-xs, l’ancien shadow devient shadow-sm, etc. Même logique pour rounded-sm/rounded-xs et blur-sm/blur-xs.

Les classes shadow-sm existantes compilent toujours, mais avec la nouvelle valeur, plus marquée. C’est un changement silencieux : pas d’erreur de build, juste un rendu différent.

- <input class="form-input border border-gray-300 rounded-md shadow-sm" />
+ <input class="form-input border border-gray-300 rounded-md shadow-xs" />

Nous en avons profité pour recalibrer visuellement toute l’échelle (menus déroulants shadow-lgshadow-md, modales shadow-xlshadow-lg) : à valider écran par écran, pas de règle automatique.

Le modificateur ! passe de préfixe à suffixe

En v3, !important s’appliquait avec ! avant la classe. En v4, il se place après :

- <div class="!flex !bg-red-500">
+ <div class="flex! bg-red-500!">

Pour @apply, la forme v3 @apply p-0 !important (ou #{!important} en Sass) ne compile plus. La recommandation v4 est d’utiliser du CSS natif directement :

- @apply p-0 #{!important};
+ padding: 0 !important;

Ou, si @apply est incontournable : @apply p-0!; (suffixe ! sur chaque utilitaire).

Le line-height hérité devient un ratio

En v3, text-sm posait un line-height en longueur fixe (1.25rem), héritée telle quelle.

En v4, il est exprimé relativement à la font-size : un pseudo-élément avec sa propre font-size recalcule le ratio et change de hauteur. Nos labels de formulaire avec astérisque required (en font-size: 1.2em) gonflaient de quelques pixels, corrigé par un line-height: 1.25rem explicite.

Impact des utilitaires dépréciés de la v3 qui ont été supprimés

Les classes retirées ne génèrent plus rien : pas d’erreur, la classe est simplement absente du CSS final. Deux régressions typiques rencontrées :

  • l’overlay de nos modales bg-black bg-opacity-25 s’affichait noir opaque (bg-opacity-25 supprimé) → bg-black/25 ;
  • le liseré ring-black ring-opacity-5 de nos menus devenait un ring noir pleinring-black/5.

Le modificateur /opacité (bg-black/25) existait déjà en v3, mais sa mécanique interne change en v4 : il ne lit plus une variable --tw-bg-opacity figée au build, il résout la couleur avec color-mix(in oklab, black 25%, transparent). C’est ce qui permet de passer currentColor en argument de /opacité (border-current/50 par exemple) : color-mix() sait combiner n’importe quelle couleur, y compris une couleur dynamique comme currentColor vue plus haut sur border/ring, ce qu’une variable RGBA figée au build ne pouvait pas faire.

Autres suppressions croisées dans le code : flex-shrink-0shrink-0, flex-growgrow, placeholder-gray-400placeholder:text-gray-400, et la palette blue-gray qui n’existe plus (→ gray).

Deux autres changements de syntaxe à surveiller :

  • outline-none change de sens : en v4 il pose outline-style: none (invisible mais pas de contour transparent). Pour l’équivalent accessible v3 (anneau transparent pour les screen readers), utiliser outline-hidden.
  • Les variables CSS en valeur arbitraire changent de délimiteur : bg-[--brand-color]bg-(--brand-color) (parenthèses au lieu de crochets).

Liste complète dans le guide de migration.

Autres changements ponctuels

  • Le plombage interne des dégradés a changé (--tw-gradient-position) : nos surcharges manuelles de --tw-gradient-from/to ne fonctionnaient plus → à remplacer par background-image: linear-gradient(...) en CSS natif.
  • Les classes de dégradé sont renommées : bg-gradient-to-r devient bg-linear-to-r (idem pour -to-b, -to-l, etc.), au profit d’une API unifiée qui couvre aussi les dégradés coniques (bg-conic) et radiaux (bg-radial). Contrairement au renommage shadow/rounded, l’ancienne classe ne compile plus du tout : l’outil de migration officiel npx @tailwindcss/upgrade automatise ce renommage, mais nous l’avons vérifié manuellement écran par écran sur nos dégradés de boutons et de bandeaux.
  • Le Preflight évolue : couleur des placeholders, cursor: default sur les boutons.
  • L’ordre de stacking des variants change de droite-gauche (v3) à gauche-droite (v4) : first:*:pt-0 (v3) devient *:first:pt-0 (v4). Aucun plantage à la compile, juste un rendu incorrect si ce pattern est utilisé.
  • transition-transform couvre désormais aussi les propriétés translate, scale et rotate, dissociées de transform en custom properties séparées. Nos boutons animés en transition-[transform,opacity] ne transitaient plus leur hover:scale-105 : il a fallu lister explicitement transition-[transform,translate,scale,rotate,opacity], une régression que ni l’outil de migration ni le guide officiel ne signalent.

Nettoyage des dépendances

  • Nous avons retiré @tailwindcss/forms (une dépendance de moins) : cette librairie n’étant plus mentionnée dans la documentation de la v4 et nous avons remarqué que nous avions très peu d’impact grâce à nos composants internes.
  • Suppression des librairies postcss, sass et babel au profit de la gestion interne preprocessor du CSS natif de Tailwind v4.

4. Déclarer les variables de thème : bundle central, config projet

C’est là que la v4 change la donne pour un design system multi-projets.

En v3, le mapping des couleurs vivait dans le tailwind.config.js de chaque projet.

En v4, le thème est un fichier CSS, donc publiable par le bundle et importable comme n’importe quel style.

Le bundle publie le thème

Le design system expose un theme.css : les utilitaires (bg-primary, text-danger…) y sont définis une seule fois, branchés sur des variables runtime que chaque projet fournit.

/* vendor/smartbooster/platform-core-bundle/assets/styles/theme.css */
@import "tailwindcss/theme.css" layer(theme) theme(static);

@theme inline static {
  --color-primary-lighter: var(--primary-lighter);
  --color-primary: var(--primary-default);
  --color-primary-dark: var(--primary-dark);

  --color-danger: var(--danger-color);
  --color-success: var(--success-color);
  /* ... */
}

Les deux options du bloc @theme sont importantes :

  • inline : la classe générée pointe directement sur la variable runtime (.bg-primary { background-color: var(--primary-default) }) au lieu d’une variable intermédiaire figée sur :root. C’est ce qui rend possibles toutes les surcharges scopées de la section suivante.
  • static : les variables sont émises même si aucune classe ne les utilise, pour le code qui les lit au runtime (getComputedStyle en JS ou CSS legacy).

Réduire le thème par défaut

Le thème par défaut de Tailwind embarque des centaines de couleurs, espacements et tailles de police que nos 80+ composants n’utilisent jamais. Le publier tel quel depuis le bundle gonflerait inutilement le CSS de chaque projet consommateur.

La directive @theme accepte initial pour vider une famille entière de variables avant de ne redéclarer que celles réellement utilisées :

@theme {
  --color-*: initial; /* on retire toute la palette de couleurs par défaut de Tailwind */
  --font-*: initial;  /* idem pour les familles de polices par défaut */

  --color-primary-lighter: var(--primary-lighter);
  --color-primary: var(--primary-default);
  /* ... uniquement les tokens réellement consommés par nos composants */
}

Ce nettoyage réduit mécaniquement le CSS généré : plus la moindre variable de couleur, de police ou d’espacement non utilisée n’est émise, ni par le bundle, ni par les projets qui l’importent.

Le projet fournit les valeurs, par interface

Chaque interface a sa propre entrée de build (admin/main.js, app/main.js), donc son propre CSS de sortie et son propre fichier de tokens. Le projet ne déclare que des valeurs :

/* assets/admin/styles/base.css : interface back-office */
:root {
  --primary-lighter: var(--color-blue-200);
  --primary-default: var(--color-blue-600);
  --primary-dark: var(--color-blue-700);
  --danger-color: var(--color-red-600);
  /* ... */
}
/* assets/app/styles/base.css : interface espace client */
:root {
  --primary-lighter: var(--color-emerald-200);
  --primary-default: var(--color-emerald-600);
  --primary-dark: var(--color-emerald-700);
  /* ... */
}

Chaque main.css d’interface assemble le tout :

/* assets/admin/styles/main.css */
@import "../../../vendor/smartbooster/platform-core-bundle/assets/styles/base.css";
@import "../../../vendor/smartbooster/platform-core-bundle/assets/styles/theme.css";
@import "base.css";
@import "tailwindcss";

Résultat : les espaces de noms sont disjoints, le bundle possède les --color-* (namespace Tailwind), le projet possède les tokens runtime (--primary-*, --danger-color…).

Aucune bataille de cascade, et l’ordre des imports n’a pas d’incidence.

v3v4
Mapping couleurs → utilitairesrecopié dans le tailwind.config.js de chaque projetune fois, dans le theme.css du bundle
Valeurs du thème_base.scss projetbase.css par interface
Safelist du Design Systèmerecopiée par projetpubliable par le bundle (@source inline dans un CSS importé)
Changement de thèmerebuild + configredéfinir des variables CSS, à n’importe quel scope

5. Surcharger le thème : par interface, par zone, par composant

Grâce à @theme inline, les classes résolvent la variable au point d’usage.

Toute redéclaration de token sur un ancêtre est donc prise en compte, sans toucher au bloc @theme.

Par interface

C’est le cas nominal vu ci-dessus : deux entrées de build, deux base.css, deux rendus, mais un seul et même design system.

Par zone, avec un attribut data-theme

Pour faire cohabiter deux thèmes dans la même page (aperçu d’un thème client dans le back-office, dark mode, section white-label) :

[data-theme="espace-client"] {
  --primary-default: var(--color-emerald-600);
  --primary-dark: var(--color-emerald-700);
}
<section data-theme="espace-client">
  <!-- tous les bg-primary, text-primary, boutons du DS… rendent en emerald ici -->
</section>

En v3 (ou en v4 sans inline), cette surcharge serait silencieusement ignorée : la variable intermédiaire est résolue une fois pour toutes sur :root, et les descendants héritent de la valeur figée.

Le sélecteur [data-theme="espace-client"] reste du CSS brut : il ne sert qu’à surcharger des variables, pas à conditionner des classes utilitaires. Si le besoin évolue vers des styles Tailwind conditionnés par la zone (espace-client:bg-primary par exemple), @custom-variant formalise le même sélecteur en variant réutilisable partout dans le HTML :

@custom-variant espace-client (&:where([data-theme="espace-client"], [data-theme="espace-client"] *));
<div class="espace-client:rounded-full">...</div>

Nous n’en avons pas eu besoin sur ce bundle puisque la surcharge de variables suffit à tout notre theming, mais c’est la voie à prendre le jour où une zone a besoin d’un style structurel différent, pas seulement d’une couleur.

Par composant, via des variables sémantiques

Le même mécanisme descend au niveau composant.

Nos composants ne consomment pas les tokens bruts mais des variables sémantiques déclarées dans le thème :

:root {
  /* base.css : les variables sémantiques dérivent des tokens */
  --primary-background-color: var(--primary-default);
  --primary-hover-background-color: var(--primary-dark);
  --sidebar-background-color: #172554;
  --sidebar-text-color: var(--primary-lighter);
}

Si on prend l’exemple du composant UiButton qui consomme uniquement les variables sémantiques, jamais les tokens bruts :

/* UiButton, style simplifié */
.btn {
  background-color: var(--primary-background-color);
}
.btn:hover {
  background-color: var(--primary-hover-background-color);
}

Créer une variante ne demande alors ni nouvelle classe utilitaire ni surcharge de spécificité, on redéclare la variable au scope du composant :

/* variante CTA du bouton : seul le token local change */
.btn-cta {
  --primary-background-color: var(--color-orange-600);
  --primary-hover-background-color: var(--color-orange-700);
}
<UiButton class="btn-cta">Souscrire</UiButton>

C’est la même règle à trois étages : le bundle définit le mapping, l’interface définit les valeurs, chaque scope (zone, composant) peut les redéfinir localement.

Note Vue SFC : pour utiliser @apply dans un <style scoped> sans que Tailwind reduplique tout le CSS, utiliser @reference à la place d’un @import :

<style scoped>
@reference "../../styles/theme.css";
.my-component { @apply text-primary font-medium; }
</style>

@reference importe uniquement les définitions (thème, utilitaires) sans émettre de CSS supplémentaire dans le bundle.

Tailwind variants : pourquoi nous ne les utilisons pas

Une autre approche existe pour gérer des variantes de composant : la librairie tailwind-variants (ou son équivalent class-variance-authority), qui propose une fonction tv() centralisant les classes par variante dans un fichier de configuration JS.

Reprise du même UiButton, avec une variante type valant primary ou cta :

// UiButton.variants.js
import { tv } from "tailwind-variants";

export const button = tv({
  base: "rounded-md px-4 py-2 font-medium transition-colors",
  variants: {
    type: {
      primary: "bg-primary text-white hover:bg-primary-dark",
      cta: "bg-orange-600 text-white hover:bg-orange-700",
    },
  },
  defaultVariants: {
    type: "primary",
  },
});
<UiButton type="cta">Souscrire</UiButton>

Note : chez SmartBooster, nous n’utilisons pas les Tailwind variants sur ce design system. Cette approche impose une configuration JavaScript (le fichier *.variants.js et sa fonction tv()) qui va à l’encontre de la logique CSS-first de la v4 : le thème et le mapping vivent en CSS, les variantes se retrouveraient elles seules pilotées depuis du JS. L’approche par variables CSS sémantiques vue plus haut (--primary-background-color) reste notre standard : une seule surcharge de token en CSS, sans dépendance ni fichier de configuration séparé.


6. Responsive et media queries

Même logique de centralisation pour le responsive : en v3, les breakpoints étaient du JS (theme.screens) recopié par projet ; en v4, ce sont des variables de thème du namespace --breakpoint-*, qui pilotent à la fois les variants (sm:, lg:…) et les utilitaires dérivés.

/* v3 : tailwind.config.js, par projet */
theme: {
  screens: { sm: '640px', md: '768px', lg: '1024px', xl: '1280px' },
}
/* v4 : dans le theme.css du bundle, le standard DS hérité par tous les projets */
@theme {
  --breakpoint-xs: 30rem;    /* breakpoint additionnel du DS */
  --breakpoint-2xl: 100rem;  /* élargi pour les écrans métier */
}

/* v4 : dans le main.css d'un projet, override ponctuel */
@theme {
  --breakpoint-lg: 64rem;
}

Points complémentaires :

  • Breakpoints one-shot : plus besoin de toucher à la config pour un cas isolé. On écrit la valeur directement dans le template avec des valeurs arbitraires :
<!-- s'active à partir de 900px -->
<div class="min-[900px]:flex">…</div>

<!-- masqué en dessous du breakpoint lg défini dans le thème -->
<nav class="max-lg:hidden">…</nav>
  • Container queries natives : en v3, les container queries nécessitaient le plugin @tailwindcss/container-queries. En v4, elles sont intégrées : on marque un conteneur avec @container, et ses enfants peuvent utiliser les variants @sm:, @lg:, etc., le breakpoint est alors relatif à la taille du conteneur, pas du viewport.
<div class="@container">
  <div class="@sm:flex @lg:grid">…</div>
</div>

Sur un bundle partagé, plusieurs conteneurs peuvent s’imbriquer (une card dans une sidebar, elle-même dans une grille) : nommer le conteneur avec @container/nom cible explicitement le bon niveau au lieu du plus proche. Un variant @md: seul est déjà une largeur minimale ; combiné à @max-*, il définit une plage, utile pour une largeur intermédiaire propre à un composant du design system :

<div class="@container/sidebar">
  <div class="@container/card">
    <!-- @lg/card:flex cible le conteneur "card" le plus proche, pas "sidebar" -->
    <div class="@lg/card:flex @md/card:@max-xl/card:gap-6">…</div>
  </div>
</div>

7. Profiter des nouveautés depuis la migration

La migration décrite plus haut portait sur la base posée par la v4.0. Depuis, les releases mineures v4.1, v4.2 et v4.3 ont ajouté des utilitaires qu’il vaut la peine d’adopter maintenant que le socle CSS-first est en place, plutôt que de les rattraper un par un au fil des besoins :

  • Dégradation mieux gérée sur les anciens navigateurs (v4.1) : la v4.0 rendait mal certaines couleurs et ombres sur les navigateurs juste en dessous du socle Safari 16.4 évoqué en partie 1 (couleurs oklch, propriétés dépendant de @property). La v4.1 a ajouté des fallbacks au niveau du framework : le rendu reste correct même sur d’anciens Safari ou Firefox, avec au pire une nuance de couleur légèrement différente. Le point de vigilance du début de l’article est donc moins bloquant qu’au lancement de la v4.

  • starting: pour les transitions d’apparition : basé sur @starting-style, ce variant anime l’apparition d’un élément (popover, menu déroulant, modale) sans JavaScript. Utile pour nos composants d’interface qui géraient jusque-là ces transitions à la main dans UiButton, UiModal et consorts. Le support navigateur suit celui évoqué en partie 1 pour @starting-style (Safari 17.5, Chrome 117), donc à vérifier au cas par cas.

  • not-*, le vrai :not() CSS : remplace les contournements en @apply négatif ou en classes utilitaires dupliquées qu’on utilisait pour exclure un état précis.

  • @source not et @source inline() étendu (v4.1) : @source not "chemin" exclut explicitement un dossier de la détection automatique, utile pour ignorer un vendor/ tiers volumineux sans y aller au glob. La syntaxe @source inline() accepte désormais des plages numériques (bg-red-{100..900..100}), ce qui simplifie encore la safelist du bundle par rapport à l’énumération manuelle qu’on utilise en partie 2.

  • Nouvelles utilitaires de scrollbar (v4.3) : scrollbar-thumb-* et scrollbar-track-* permettent de teinter la scrollbar avec nos tokens de marque, cohérent avec l’architecture de theming par interface décrite en partie 5.

  • @variant empilés et composés (v4.3) : en CSS, deux syntaxes couvrent respectivement les combinaisons et les unions de variants, utile pour les composants qui s’appuient sur @apply comme .btn-cta en partie 5 :

    .btn-cta {
      /* combinaison : hover ET focus */
      @variant hover:focus {
        background-color: var(--color-orange-700);
      }
      /* union : hover OU focus */
      @variant hover, focus {
        outline: 2px solid var(--color-orange-500);
      }
    }
  • Valeurs par défaut pour les utilitaires dynamiques (v4.3) : --value(integer, --default(4)) dans une définition @utility permet à la classe sans suffixe (tab) de retomber sur une valeur par défaut, tout en gardant tab-2 fonctionnel. Un complément direct à l’exemple @utility tab-4 de la partie 2.

  • API de dégradés élargie : en complément du renommage bg-linear-* vu en partie 3, la v4.0 a ajouté bg-conic-*, bg-radial-* et des modificateurs d’interpolation (bg-linear-to-r/oklch) pour des dégradés plus vifs sans CSS custom.


Conclusion

La partie mécanique de la migration (renommages, utilitaires supprimés, config CSS-first) se traite en quelques heures avec le guide officiel et une passe de recette visuelle. Les vraies régressions sont silencieuses (opacités, ombres, bordures), c’est l’écran qui les révèle, pas le build.

Le vrai gain est architectural : le thème étant devenu du CSS, notre design system publie enfin sa configuration (mapping d’utilitaires, safelist, breakpoints) comme il publie ses composants. Les projets ne possèdent plus que leurs valeurs, par interface, par zone, par composant.


Liens utiles

Mathieu Ducrot
Mathieu Ducrot CTO
Mots clés :
#Qualité #Productivité

Articles similaires

Vous avez un projet ?

Contactez-nous pour savoir comment nous pouvons vous aider.