Stylisation

Stylisation conditionnelle et composable avec des classes Tailwind.

Les bibliothèques de composants modernes ont besoin de systèmes de stylisation flexibles capables de gérer des exigences complexes sans sacrifier l'expérience développeur. La combinaison de Tailwind CSS avec une fusion intelligente des classes s'est imposée comme un modèle puissant pour construire des composants personnalisables.

Cette approche résout la tension fondamentale entre fournir des valeurs par défaut sensées et permettre une personnalisation complète — un défi qui a pesé sur les bibliothèques de composants pendant des années.

Le problème des approches de stylisation traditionnelles

Les approches CSS traditionnelles conduisent souvent à des guerres de spécificité, des conflits de style et des remplacements imprévisibles. Lorsque vous passez className="bg-blue-500" à un composant qui a déjà bg-red-500, lequel l'emporte ?

Sans gestion appropriée, les deux classes s'appliquent et le résultat dépend de nombreux facteurs — l'ordre source du CSS, la spécificité des classes, l'algorithme de fusion des classes du bundler, etc.

Fusionner les classes de manière intelligente

La bibliothèque tailwind-merge résout ce problème en comprenant la structure des classes Tailwind et en résolvant intelligemment les conflits. Lorsque deux classes ciblent la même propriété CSS, elle ne conserve que la dernière.

Sans tailwind-merge
// Both bg-red-500 and bg-blue-500 apply - unpredictable result
<Button className="bg-blue-500" />
// Renders: className="bg-red-500 bg-blue-500"
Avec tailwind-merge
import { twMerge } from 'tailwind-merge';

// bg-blue-500 wins as it comes last
const className = twMerge('bg-red-500', 'bg-blue-500');
// Returns: "bg-blue-500"

Cela fonctionne pour toutes les utilités Tailwind :

twMerge('px-4 py-2', 'px-8'); // Returns: "py-2 px-8"
twMerge('text-sm', 'text-lg'); // Returns: "text-lg"
twMerge('hover:bg-red-500', 'hover:bg-blue-500'); // Returns: "hover:bg-blue-500"

La bibliothèque comprend également le système de modificateurs de Tailwind :

// Modifiers are handled correctly
twMerge('hover:bg-red-500 focus:bg-red-500', 'hover:bg-blue-500');
// Returns: "focus:bg-red-500 hover:bg-blue-500"

Classes conditionnelles

Souvent, vous devez appliquer des classes de façon conditionnelle en fonction des props ou de l'état. La bibliothèque clsx fournit une API claire pour cela :

Utilisation de clsx
import clsx from 'clsx';

// Basic conditionals
clsx('base', isActive && 'active');
// Returns: "base active" (if isActive is true)

// Object syntax
clsx('base', {
  'active': isActive,
  'disabled': isDisabled,
});

// Arrays
clsx(['base', isLarge ? 'text-lg' : 'text-sm']);

// Mixed
clsx(
  'base',
  ['array-item'],
  { 'object-conditional': true },
  isActive && 'conditional'
);

Un schéma courant consiste à fusionner un ensemble de classes par défaut avec les props entrantes, ainsi qu'avec toute logique personnalisée que nous avons :

component.tsx
const Component = ({ className, ...props }: ComponentProps) => {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div
      className={cn(
        "rounded-lg border bg-white shadow-sm",
        isOpen && "bg-blue-500",
        className
      )}
      {...props}
    />
  );
};

La fonction utilitaire cn

La fonction cn, popularisée par shadcn/ui, combine clsx et tailwind-merge pour vous offrir à la fois une logique conditionnelle et une fusion intelligente :

lib/utils.ts
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

La puissance vient de l'ordre — styles de base en premier, conditionnels ensuite, surcharges utilisateur en dernier. Cela garantit un comportement prévisible tout en maintenant une personnalisation complète.

Class Variance Authority (CVA)

Pour les composants complexes avec de nombreuses variantes, gérer manuellement les classes conditionnelles devient ingérable. Class Variance Authority (CVA) fournit une API déclarative pour définir les variantes de composants.

Par exemple, voici un extrait du composant Button de shadcn/ui :

@/components/ui/button.tsx
const buttonVariants = cva(
  "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground hover:bg-primary/90",
        destructive:
          "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
        outline:
          "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
        secondary:
          "bg-secondary text-secondary-foreground hover:bg-secondary/80",
        ghost:
          "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
        link: "text-primary underline-offset-4 hover:underline",
      },
      size: {
        default: "h-9 px-4 py-2 has-[>svg]:px-3",
        sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
        lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
        icon: "size-9",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
)

Bonnes pratiques

1. L'ordre est important

Appliquez toujours les classes dans cet ordre :

  1. Styles de base (toujours appliqués)
  2. Styles de variante (en fonction des props)
  3. Styles conditionnels (en fonction de l'état)
  4. Surcharges utilisateur (prop className)
className={cn(
  'base-styles',            // 1. Base
  variant && variantStyles, // 2. Variants
  isActive && 'active',     // 3. Conditionals
  className                 // 4. User overrides
)}

2. Documentez vos variantes

Utilisez TypeScript et JSDoc pour documenter ce que fait chaque variante :

type ButtonProps = {
  /**
   * The visual style of the button
   * @default "primary"
   */
  variant?: 'primary' | 'secondary' | 'destructive' | 'ghost';

  /**
   * The size of the button
   * @default "md"
   */
  size?: 'sm' | 'md' | 'lg';
};

3. Extraire les motifs répétés

Si vous vous retrouvez à écrire la même logique conditionnelle à répétition, extrayez-la :

utils/styles.ts
export const focusRing = 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500';
export const disabled = 'disabled:pointer-events-none disabled:opacity-50';

// Use in components
className={cn(focusRing, disabled, className)}

Guide de migration

Si vous migrez depuis une autre approche de stylisation, voici comment adapter les schémas courants :

Depuis CSS Modules

Avant - CSS Modules
import styles from './Button.module.css';

<button className={`${styles.button} ${styles[variant]} ${className}`} />
Après - cn + Tailwind
import { cn } from '@/lib/utils';

<button className={cn(
  'px-4 py-2 rounded-lg',
  variant === 'primary' && 'bg-blue-500 text-white',
  className
)} />

Depuis styled-components

Avant - styled-components
const Button = styled.button<{ $primary?: boolean }>`
  padding: 8px 16px;
  background: ${props => props.$primary ? 'blue' : 'gray'};
`;
Après - cn + Tailwind
function Button({ primary, className, ...props }) {
  return (
    <button
      className={cn(
        'px-4 py-2',
        primary ? 'bg-blue-500' : 'bg-gray-500',
        className
      )}
      {...props}
    />
  );
}

Considérations de performance

Les deux bibliothèques clsx et tailwind-merge sont hautement optimisées, mais gardez ces conseils à l'esprit :

  1. Définissez les variantes en dehors des composants - les variantes CVA doivent être définies en dehors du composant pour éviter d'être recréées à chaque rendu.

  2. Mémorisez les calculs complexes - si vous avez une logique conditionnelle coûteuse, envisagez de la mémoïser :

const className = useMemo(
  () => cn(
    baseStyles,
    expensiveComputation(props),
    className
  ),
  [props, className]
);
  1. Utilisez des variables CSS pour les valeurs dynamiques - au lieu de générer des classes dynamiquement, utilisez des variables CSS :
Privilégiez les variables CSS
// Good
<div
  className="bg-[var(--color)]"
  style={{ '--color': dynamicColor } as React.CSSProperties}
/>

// Avoid
<div className={`bg-[${dynamicColor}]`} />

La combinaison de Tailwind CSS, d'une fusion intelligente des classes et d'API de variantes fournit une base solide pour la stylisation des composants. Cette approche s'étend des simples boutons aux systèmes de design complexes tout en maintenant la prévisibilité et l'expérience développeur.