Tailwind est rapide la première semaine et salissant au sixième mois si chaque écran est un tas de utilities isolées. Les équipes qui restent à l'aise avec partagent quelques habitudes: elles conçoivent des systèmes de layout, elles extraient des composants quand un motif se répète, elles câblent le dark mode une fois à la racine, et elles refusent les chaînes de classes sans structure.

Ce n'est pas un tutoriel de démarrage. On suppose que vous utilisez déjà Tailwind. L'objectif: du code qui reste lisible quand un troisième ingénieur ouvre la PR.


Le class soup est un problème de process, pas de CSS

Le "class soup" ressemble à ça: quarante utilities sur un seul div, trois quasi-copies de la même card sur trois routes, des espacements qui dérivent (p-4 à côté de p-5 à côté de px-3.5), et des variantes dark collées sur chaque feuille au lieu d'être héritées.

Le soup veut souvent dire une de ces choses:

  1. Pas de primitives de layout partagées. Chaque page a réinventé max-width, gutters et rythme vertical.
  2. Pas de règle d'extraction. Le même bouton ou panneau a été retapé au lieu de devenir un composant.
  3. Les design tokens n'ont jamais quitté les defaults. Les valeurs arbitraires (w-[347px], text-[#3a7]) sont devenues normales.
  4. Le dark mode est arrivé trop tard. Chaque élément a eu sa paire dark: au lieu d'une surface de thème.

Les utilities vont bien. Le copier-coller sans borne de utilities, non. La solution est la structure, pas un retour à BEM.


Systèmes de layout: arrêtez de redessiner le shell de page

Avant les composants, figez le chrome de page. La plupart des produits n'ont besoin que de quelques pièces de layout:

Primitive Rôle Utilities typiques
Container Max width + padding horizontal mx-auto w-full max-w-6xl px-4 sm:px-6
Stack Rythme vertical flex flex-col gap-4 (ou gap-6 / gap-8 en variantes nommées)
Cluster Rangée avec wrap et gap flex flex-wrap items-center gap-2
Grid Colonnes responsive grid gap-6 md:grid-cols-2 lg:grid-cols-3
Section Padding vertical de section py-12 md:py-16

Exemple en React (même idée en Vue, Svelte ou Angular):

export function Container({
  children,
  className = "",
}: {
  children: React.ReactNode;
  className?: string;
}) {
  return (
    <div className={`mx-auto w-full max-w-6xl px-4 sm:px-6 ${className}`}>
      {children}
    </div>
  );
}

export function Stack({
  children,
  gap = "md",
  className = "",
}: {
  children: React.ReactNode;
  gap?: "sm" | "md" | "lg";
  className?: string;
}) {
  const gaps = { sm: "gap-2", md: "gap-4", lg: "gap-8" } as const;
  return (
    <div className={`flex flex-col ${gaps[gap]} ${className}`}>{children}</div>
  );
}

Règles qui tiennent:

  • Une échelle de max-width pour le contenu (max-w-3xl prose, max-w-6xl app, full-bleed seulement à dessein).
  • Espacement sur une courte échelle (2 / 4 / 6 / 8 / 12), pas tous les nombres de l'échelle.
  • Gutters qui grandissent une fois en sm ou md, puis restent stables.
  • Préférez gap au margin sur les enfants d'un stack. Le gap compose; le margin s'effondre et se bat avec les composants.

Une page faite de Container + Stack + une grille de cards se review déjà mieux qu'un arbre libre de divs.

Layout avec CSS grid et structure logique

Pour les shells de dashboard, décrivez les régions une fois:

<div class="min-h-screen grid grid-cols-1 lg:grid-cols-[16rem_1fr]">
  <aside class="border-r border-slate-200 bg-slate-50 dark:border-slate-800 dark:bg-slate-950">
    <!-- nav -->
  </aside>
  <div class="flex min-h-0 flex-col">
    <header class="border-b border-slate-200 px-4 py-3 dark:border-slate-800">
      <!-- top bar -->
    </header>
    <main class="flex-1 overflow-auto p-4 md:p-6">
      <!-- page content -->
    </main>
  </div>
</div>

Mettez bordures et fond du shell sur le shell. Laissez cards et formulaires aux composants. Mélanger le chrome du shell dans chaque page, c'est ainsi que le dark mode et la nav responsive cassent sur une route et pas sur une autre.


Extraction de composants: quand arrêter de coller des utilities

Extrayez quand deux endroits ou plus partagent structure et intention, pas quand deux endroits partagent flex par hasard. Une bonne extraction a un nom qu'un designer reconnaîtrait: Button, Card, Field, Badge, Alert, Modal, EmptyState.

Extrayez l'API de variantes, pas un tiroir de classes

Mauvais motif: un Button qui n'accepte que className, et chaque call site reconstruit la variante:

// Fragile: chaque call site réinvente le bouton
<button className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500">
  Save
</button>

Meilleur motif: une petite map de variantes (à la main ou avec cva / tailwind-variants):

const buttonVariants = {
  base: "inline-flex items-center justify-center rounded-lg text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 disabled:pointer-events-none disabled:opacity-50",
  variant: {
    primary: "bg-indigo-600 text-white hover:bg-indigo-500",
    secondary: "bg-slate-100 text-slate-900 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700",
    ghost: "bg-transparent text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800",
  },
  size: {
    sm: "h-8 px-3",
    md: "h-10 px-4",
    lg: "h-11 px-6",
  },
} as const;

type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
  variant?: keyof typeof buttonVariants.variant;
  size?: keyof typeof buttonVariants.size;
};

export function Button({
  variant = "primary",
  size = "md",
  className = "",
  ...props
}: ButtonProps) {
  return (
    <button
      className={`${buttonVariants.base} ${buttonVariants.variant[variant]} ${buttonVariants.size[size]} ${className}`}
      {...props}
    />
  );
}

Les call sites restent courts. La review design se fait dans un fichier. Quand la couleur de marque change, vous éditez la map une fois.

@apply est un outil, pas un mode de vie

Dans des CSS modules ou une couche composants:

@layer components {
  .card {
    @apply rounded-xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900;
  }
}

Utilisez @apply pour des surfaces stables et répétées que vous n'avez pas besoin de paramétrer à chaque appel. Préférez des composants avec props quand les variantes comptent. Évitez de transformer Tailwind en second BEM où chaque classe est un énorme @apply que personne ne peut surcharger.

La trappe de sortie className

Autorisez className sur les primitives pour des ajustements de layout (className="mt-6 w-full"). N'autorisez pas les call sites à repeindre le composant avec vingt utilities de couleur. S'il faut un nouveau look, ajoutez une variante.


Design tokens: possédez l'échelle avant qu'elle ne vous possède

Les defaults sont un point de départ. Les apps en production ont souvent besoin d'une fine couche de tokens dans tailwind.config (v3) ou @theme (v4):

// tailwind.config.js (style v3)
module.exports = {
  theme: {
    extend: {
      colors: {
        brand: {
          50: "#eef2ff",
          500: "#6366f1",
          600: "#4f46e5",
          700: "#4338ca",
        },
        surface: {
          DEFAULT: "#ffffff",
          muted: "#f8fafc",
          inverse: "#0f172a",
        },
      },
      borderRadius: {
        card: "0.75rem",
      },
      boxShadow: {
        card: "0 1px 2px 0 rgb(15 23 42 / 0.06)",
      },
      maxWidth: {
        content: "42rem",
        app: "72rem",
      },
    },
  },
};

Ensuite vous écrivez bg-brand-600, rounded-card, max-w-app. Les valeurs arbitraires devraient un peu gêner en review: OK pour un hero marketing ponctuel, mauvais pour l'espacement et la couleur de marque du quotidien.

Si le design livre des tokens Figma, mappez-les une fois dans le theme. Ne copiez pas des hex à la main dans le JSX pour toujours.


Dark mode sans thrash

Choisissez une stratégie et documentez-la:

  1. Stratégie de classe (class sur html): meilleure pour les toggles utilisateur et les apps SSR qui fixent la préférence depuis cookie ou local storage avant le paint.
  2. Stratégie media (prefers-color-scheme): correcte pour les sites de contenu sans toggle.
// v3
module.exports = {
  darkMode: "class",
  // ...
};

Setup racine (conceptuel):

<html class="h-full antialiased">
  <body class="min-h-full bg-white text-slate-900 dark:bg-slate-950 dark:text-slate-100">
    ...
  </body>
</html>

Les surfaces de thème battent le dark: feuille par feuille

Préférez des surfaces sémantiques:

// Les surfaces gèrent light/dark; les enfants héritent la couleur de texte
export function Card({ children, className = "" }: { children: React.ReactNode; className?: string }) {
  return (
    <div
      className={`rounded-card border border-slate-200 bg-white text-slate-900 shadow-card dark:border-slate-800 dark:bg-slate-900 dark:text-slate-100 ${className}`}
    >
      {children}
    </div>
  );
}

Dans la card, utilisez text-slate-600 dark:text-slate-300 seulement là où le contraste demande un second cran. Ne restitez pas le fond sur chaque div imbriqué.

Les variables CSS se marient bien avec Tailwind pour des thèmes au-delà du noir/blanc:

:root {
  --color-bg: 255 255 255;
  --color-fg: 15 23 42;
}
.dark {
  --color-bg: 2 6 23;
  --color-fg: 241 245 249;
}
// couleurs de thème en canaux rgb pour que les modificateurs d'opacité marchent
colors: {
  canvas: "rgb(var(--color-bg) / <alpha-value>)",
  ink: "rgb(var(--color-fg) / <alpha-value>)",
}

Puis bg-canvas text-ink bascule avec la classe racine. C'est ainsi que les apps multi-marque ou multi-thème restent saines.

Évitez le flash du mauvais thème

S'il y a un toggle, fixez la classe avant le premier paint (script inline dans head, ou classe rendue côté serveur depuis cookie). Un useEffect React qui bascule le thème après hydratation laissera un flash blanc aux utilisateurs dark mode. C'est un bug produit, pas un bug Tailwind.

Le contraste fait partie de l'API

text-slate-400 sur bg-slate-900 peut passer à l'œil et rater le WCAG. Vérifiez texte primaire, secondaire, bordures et focus rings dans les deux thèmes. Mettez les styles de focus dans les maps de variantes partagées pour que personne ne livre un contrôle custom sans ring.


Habitudes qui gardent les utilities lisibles

Groupe les classes dans un ordre stable

Choisissez un ordre et tenez-vous-y. Un ordre courant:

  1. Layout / display (flex, grid, block)
  2. Position / taille (relative, w-full, h-10)
  3. Spacing (p-4, gap-2, m-0)
  4. Typographie
  5. Couleur / fond / bordure
  6. Effets (shadow, transition)
  7. Interactif (hover:, focus-visible:)
  8. Variantes dark / responsive en dernier, ou groupées avec leur propriété

Le plugin Prettier prettier-plugin-tailwindcss impose l'ordre et réduit le bruit en review.

Préférez les variantes responsive et d'état sur le composant, pas sur chaque feuille

// Préférez
<section className="grid gap-4 md:grid-cols-2">
  <Card />
  <Card />
</section>

// Évitez de répéter md: sur chaque card pour le même job de grille

Découpez les longues chaînes quand le composant reste local

Si un bloc one-off est long mais ne mérite pas encore un composant partagé, cassez-le avec une variable ou un helper clsx/cn:

const panel = cn(
  "rounded-xl border border-slate-200 bg-white p-6",
  "dark:border-slate-800 dark:bg-slate-900",
  emphasized && "ring-2 ring-brand-500",
);

Du multi-ligne lisible bat un attribut de 300 caractères.

Ne combattez pas la cascade avec ! partout

!flex et !p-0 à dix endroits signifient que l'API du composant est fausse ou qu'un parent est sur-stylé. Corrigez la propriété des styles. Les modificateurs important sont pour les échappatoires tierces, pas pour le style maison.

Defaults de contenu et de formulaires

Utilisez @tailwindcss/typography (prose) pour le markdown/CMS long au lieu de styler à la main chaque h2 et p. Utilisez un Field partagé pour label + contrôle + erreur. Les formulaires sont là où le class soup se multiplie le plus vite.


Scan et taille CSS en production

Tailwind n'émet que les classes trouvées dans les chemins de content. La douleur en production vient souvent de:

  • Construction dynamique de classes que le scanner ne voit pas:
// Mauvais: bg-indigo-600 n'apparaît jamais comme chaîne complète
const color = "indigo";
return <div className={`bg-${color}-600`} />;
// Bon: noms de classes complets dans le source
const colors = { indigo: "bg-indigo-600", rose: "bg-rose-600" } as const;
return <div className={colors.indigo} />;
  • Globs de content manquants pour les packages monorepo (./packages/ui/src/**/*.{ts,tsx}).
  • Safelists énormes qui réintroduisent du CSS inutilisé. Préférez des chaînes complètes dans le code à de larges regex de safelist.

Gardez le package design system dans la config content du scanner. Une UI library privée que Tailwind ne voit pas, c'est ainsi qu'apparaissent les "styles manquants seulement en prod".


Checklist pratique d'extraction

Quand une PR ressemble à du soup, parcourez cette liste:

  1. Est-ce le shell de page ou le contenu? Le shell va aux primitives de layout.
  2. Un contrôle nommé existe déjà? Utilisez-le; étendez les variantes si besoin.
  3. L'espacement et la typo viennent de l'échelle convenue? Remplacez les valeurs one-off.
  4. Les couleurs sont des tokens de marque ou du bruit de palette brute?
  5. Le dark mode est géré par une surface, ou par 15 paires dark: sur les feuilles?
  6. Un cn() de deux lignes rendrait ce bloc local lisible sans nouveau fichier?
  7. Un composant partagé économiserait les trois prochains call sites, pas seulement celui-ci?

Si vous n'extrayez qu'au troisième call site, vous gagnez quand même. Abstraire trop tôt chaque flex gap-2 est l'échec inverse.


À quoi ressemble un bon Tailwind à six mois

  • Les primitives de layout (Container, Stack, shell d'app) sont ennuyeuses et réutilisées.
  • Boutons, fields, cards et alerts ont une courte API de variantes.
  • Les tokens de thème couvrent marque, surfaces et rayons; les valeurs arbitraires sont rares.
  • Le dark mode est une préoccupation racine; les composants héritent surtout.
  • Les chaînes de classes sont ordonnées, souvent multi-ligne, et courtes aux call sites.
  • Le scanner voit chaque package qui émet des noms de classe.

Tailwind ne supprime pas les design systems. Il rend un design system léger moins cher à exprimer. Le pattern de production est le même que pour tout stack UI: structure partagée, variantes claires, tokens plutôt que nombres magiques, et la discipline d'extraire quand le coller-copier commence à mentir.