Tutoriels

Thématisation dynamique des couleurs en React : au-delà des variables CSS

11 min de lecture

La plupart des tutoriels de thématisation s'arrêtent au bouton bascule du mode sombre. On ajoute une classe sur <html>, on inverse quelques variables CSS, et c'est tout. Cela couvre un cas courant mais passe à côté du problème plus intéressant : que faire si les utilisateurs peuvent choisir leur propre couleur de marque ? Que faire si votre produit SaaS sert plusieurs clients, chacun avec sa propre identité ? Que faire si le thème doit générer une palette harmonieuse complète à partir d'une seule valeur hexadécimale à l'exécution ?

C'est là que commence la thématisation dynamique — et les variables CSS seules ne suffisent pas. Il vous faut des algorithmes de couleur à l'exécution, un état React qui survive à la navigation, des stratégies de persistance, et une architecture qui ne re-rende pas tout votre arbre de composants chaque fois qu'un curseur bouge.

Ce guide couvre chacune de ces couches en profondeur.


L'approche par variables CSS pour les thèmes React

La base : architecture de tokens sémantiques

Tout système de thème dynamique commence par la même fondation : des propriétés CSS personnalisées nommées selon leur rôle sémantique, pas selon leur valeur de couleur. Ne nommez pas une variable --blue-500. Nommez-la --brand-primary. La valeur peut changer ; le rôle, non :

/* globals.css */
:root {
  /* Tokens sémantiques — c'est ce qu'utilisent les composants */
  --bg-base: #F8FAFC;
  --bg-surface: #FFFFFF;
  --bg-sunken: #F1F5F9;

  --text-primary: #1E293B;
  --text-secondary: #64748B;
  --text-disabled: #94A3B8;

  --brand-primary: #2563EB;
  --brand-hover: #1D4ED8;
  --brand-subtle: #EFF6FF;
  --on-brand: #FFFFFF;

  --border-default: #E2E8F0;
  --border-strong: #CBD5E1;

  --status-error: #DC2626;
  --status-success: #16A34A;
  --status-warning: #D97706;
}

Les composants référencent les tokens, jamais les couleurs brutes :

.card {
  background: var(--bg-surface);
  border: 1px solid var(--border-default);
  color: var(--text-primary);
}

.btn-primary {
  background: var(--brand-primary);
  color: var(--on-brand);
}

.btn-primary:hover {
  background: var(--brand-hover);
}

Quand vous mettez à jour --brand-primary en JavaScript, chaque composant qui le référence se met à jour instantanément — sans re-rendu React, sans prop drilling, sans abonnements à un contexte. CSS gère la cascade.

Écrire des tokens depuis JavaScript

L'API DOM pour définir des propriétés personnalisées est simple :

document.documentElement.style.setProperty('--brand-primary', '#7C3AED');
document.documentElement.style.setProperty('--brand-hover', '#6D28D9');
document.documentElement.style.setProperty('--brand-subtle', '#EDE9FE');
document.documentElement.style.setProperty('--on-brand', '#FFFFFF');

Pour les relire :

const brand = getComputedStyle(document.documentElement)
  .getPropertyValue('--brand-primary')
  .trim();

C'est la fondation mécanique. La partie difficile est de générer l'ensemble complet de tokens à partir d'un seul hexadécimal de marque.


Génération de thème à l'exécution avec des algorithmes de couleur

Le problème des ensembles de tokens manuels

Si les utilisateurs peuvent choisir n'importe quelle couleur — y compris des couleurs inhabituelles comme #FF5733 ou #0D9488 — vous ne pouvez pas coder en dur une variante sombre, un état hover ou une couleur de texte sur la marque. Vous devez les calculer à partir de l'entrée.

Génération de l'ensemble complet de tokens

Voici une fonction complète qui prend un hexadécimal de marque et produit tous les tokens dont votre thème a besoin :

import chroma from 'chroma-js';

function generateThemeTokens(brandHex) {
  const brand = chroma(brandHex);

  // Déterminer si le texte sur la marque doit être noir ou blanc
  // Basé sur la luminance relative WCAG
  const brandLuminance = brand.luminance();
  const onBrand = brandLuminance > 0.179 ? '#000000' : '#FFFFFF';

  // Générer une gamme de la teinte claire à la nuance sombre en OKLCH
  // pour des étapes perceptuellement uniformes
  const scale = chroma.scale([
    chroma(brandHex).brighten(2.5).desaturate(0.5).hex(),
    brandHex,
    chroma(brandHex).darken(2.5).hex(),
  ]).mode('oklch').colors(11);

  return {
    '--brand-primary': brandHex,
    '--brand-hover': chroma(brandHex).darken(0.5).hex(),
    '--brand-active': chroma(brandHex).darken(1).hex(),
    '--brand-subtle': scale[1],   // Teinte très claire
    '--brand-muted': scale[2],    // Teinte claire
    '--on-brand': onBrand,

    // Primitives de gamme pour un accès complet à la palette
    '--brand-50': scale[0],
    '--brand-100': scale[1],
    '--brand-200': scale[2],
    '--brand-300': scale[3],
    '--brand-400': scale[4],
    '--brand-500': scale[5],
    '--brand-600': scale[6],
    '--brand-700': scale[7],
    '--brand-800': scale[8],
    '--brand-900': scale[9],
    '--brand-950': scale[10],
  };
}

function applyTheme(brandHex) {
  const tokens = generateThemeTokens(brandHex);
  const root = document.documentElement;
  Object.entries(tokens).forEach(([prop, value]) => {
    root.style.setProperty(prop, value);
  });
}

Paires de couleurs accessibles

Lors de la génération de thèmes à partir d'une entrée utilisateur arbitraire, l'accessibilité ne peut être présumée. Une couleur choisie par l'utilisateur peut produire un contraste insuffisant sur fond blanc. Il faut vérifier et ajuster :

function ensureContrast(foreground, background, minRatio = 4.5) {
  let color = chroma(foreground);
  let ratio = chroma.contrast(color.hex(), background);

  // Si le contraste est insuffisant, assombrir le premier plan jusqu'à ce qu'il passe
  let iterations = 0;
  while (ratio < minRatio && iterations < 20) {
    color = color.darken(0.2);
    ratio = chroma.contrast(color.hex(), background);
    iterations++;
  }

  return color.hex();
}

// Pour une étiquette texte de couleur de marque sur fond blanc
const safeLabel = ensureContrast('#FFD700', '#FFFFFF'); // Jaune ajusté pour passer le niveau AA

Appliquez ceci lors de la génération de tokens pour les éléments textuels qui apparaissent sur du blanc ou d'autres surfaces fixes.

Couleurs complémentaires et d'accent

Le Générateur de palette montre comment une couleur de base unique produit une famille harmonique complète. Vous pouvez reproduire cette logique en code par rotation de teinte :

function generateComplementaryAccent(brandHex) {
  const hsl = chroma(brandHex).hsl();
  // Rotation de teinte de 180° pour le complément, décalage de luminosité pour maintenir l'équilibre
  const complementHue = (hsl[0] + 180) % 360;
  return chroma.hsl(complementHue, hsl[1] * 0.9, hsl[2]).hex();
}

function generateAnalogousAccent(brandHex, degrees = 30) {
  const hsl = chroma(brandHex).hsl();
  const analogousHue = (hsl[0] + degrees) % 360;
  return chroma.hsl(analogousHue, hsl[1], hsl[2]).hex();
}

Contexte React pour la gestion de l'état du thème

Architecture de ThemeContext

Le contexte de thème sert de pont entre les interactions de l'utilisateur (choix d'une couleur, basculement du mode sombre) et l'application des tokens au niveau du DOM. Il doit être léger : il stocke l'état, l'applique au DOM, et expose un setter. Le calcul des couleurs se fait en dehors du contexte :

// contexts/ThemeContext.tsx
import {
  createContext,
  useContext,
  useEffect,
  useState,
  useCallback,
  type ReactNode,
} from 'react';
import { generateThemeTokens } from '../lib/theme-generator';

interface ThemeState {
  brandHex: string;
  mode: 'light' | 'dark' | 'system';
}

interface ThemeContextValue extends ThemeState {
  setBrand: (hex: string) => void;
  setMode: (mode: 'light' | 'dark' | 'system') => void;
  resolvedMode: 'light' | 'dark';
}

const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);

const STORAGE_KEY = 'app-theme';

function getStoredTheme(): ThemeState {
  try {
    const stored = localStorage.getItem(STORAGE_KEY);
    if (stored) return JSON.parse(stored) as ThemeState;
  } catch {}
  return { brandHex: '#2563EB', mode: 'system' };
}

export function ThemeProvider({ children }: { children: ReactNode }) {
  const [state, setState] = useState<ThemeState>(getStoredTheme);

  const systemDark =
    typeof window !== 'undefined'
      ? window.matchMedia('(prefers-color-scheme: dark)').matches
      : false;

  const resolvedMode: 'light' | 'dark' =
    state.mode === 'system'
      ? systemDark ? 'dark' : 'light'
      : state.mode;

  // Appliquer les tokens CSS à chaque changement d'état
  useEffect(() => {
    const tokens = generateThemeTokens(state.brandHex);
    const root = document.documentElement;
    Object.entries(tokens).forEach(([prop, value]) => {
      root.style.setProperty(prop, value as string);
    });
    root.setAttribute('data-theme', resolvedMode);
    localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
  }, [state, resolvedMode]);

  // Réagir aux changements de préférence système
  useEffect(() => {
    if (state.mode !== 'system') return;
    const media = window.matchMedia('(prefers-color-scheme: dark)');
    const handler = (e: MediaQueryListEvent) => {
      document.documentElement.setAttribute(
        'data-theme',
        e.matches ? 'dark' : 'light',
      );
    };
    media.addEventListener('change', handler);
    return () => media.removeEventListener('change', handler);
  }, [state.mode]);

  const setBrand = useCallback((hex: string) => {
    setState(prev => ({ ...prev, brandHex: hex }));
  }, []);

  const setMode = useCallback((mode: 'light' | 'dark' | 'system') => {
    setState(prev => ({ ...prev, mode }));
  }, []);

  return (
    <ThemeContext.Provider value={{ ...state, setBrand, setMode, resolvedMode }}>
      {children}
    </ThemeContext.Provider>
  );
}

export function useTheme(): ThemeContextValue {
  const ctx = useContext(ThemeContext);
  if (!ctx) throw new Error('useTheme doit être utilisé dans un ThemeProvider');
  return ctx;
}

Le composant sélecteur de couleur

Avec le contexte en place, un sélecteur de couleur devient une fine couche d'interface :

// components/BrandColorPicker.tsx
import { useTheme } from '../contexts/ThemeContext';
import { useState, useRef } from 'react';

export function BrandColorPicker() {
  const { brandHex, setBrand } = useTheme();
  const [localHex, setLocalHex] = useState(brandHex);
  const debounceRef = useRef<ReturnType<typeof setTimeout>>();

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const value = e.target.value;
    setLocalHex(value);

    // Antirebond pour la génération de tokens coûteuse
    clearTimeout(debounceRef.current);
    debounceRef.current = setTimeout(() => {
      if (/^#[0-9A-Fa-f]{6}$/.test(value)) {
        setBrand(value);
      }
    }, 150);
  };

  return (
    <div className="flex items-center gap-3">
      <label htmlFor="brand-picker" className="text-sm font-medium">
        Couleur de marque
      </label>
      <div className="relative">
        <input
          id="brand-picker"
          type="color"
          value={localHex}
          onChange={handleChange}
          className="sr-only"
        />
        <label
          htmlFor="brand-picker"
          className="block w-8 h-8 rounded-md cursor-pointer ring-2 ring-offset-2"
          style={{
            backgroundColor: localHex,
            // la couleur de l'anneau utilise le token de marque
            '--tw-ring-color': 'var(--brand-primary)',
          } as React.CSSProperties}
          aria-label={`Couleur de marque actuelle : ${localHex}`}
        />
      </div>
      <input
        type="text"
        value={localHex}
        onChange={handleChange}
        className="w-24 px-2 py-1 text-sm font-mono border rounded"
        placeholder="#2563EB"
        aria-label="Valeur hexadécimale de la couleur de marque"
      />
    </div>
  );
}

L'antirebond de 150ms est essentiel — sans lui, generateThemeTokens s'exécute à chaque frappe lors de la saisie dans le champ hexadécimal, ce qui est inutilement coûteux.


Classes de couleurs dynamiques avec Tailwind CSS

Le défi avec Tailwind et les couleurs à l'exécution

Tailwind génère des classes utilitaires au moment de la compilation. Les classes comme bg-brand-500 n'existent dans votre feuille de style que si le scanner de Tailwind les a trouvées dans vos fichiers source. Une couleur déterminée à l'exécution — par exemple, ce que l'utilisateur vient de choisir — ne sera pas dans la feuille de style.

Il existe deux solutions, adaptées à des besoins différents :

Solution 1 : pont de variables CSS

L'approche la plus propre est de définir un petit ensemble de tokens de thème Tailwind qui pointent vers des propriétés CSS personnalisées, puis de contrôler les valeurs de ces propriétés à l'exécution :

/* styles.css (Tailwind v4) */
@import "tailwindcss";

@theme {
  --color-brand: var(--brand-primary);
  --color-brand-hover: var(--brand-hover);
  --color-brand-subtle: var(--brand-subtle);
  --color-on-brand: var(--on-brand);
}

Maintenant bg-brand, text-brand et hover:bg-brand-hover sont des classes Tailwind valides qui reflètent la valeur que --brand-primary détient à l'exécution :

<button className="bg-brand text-on-brand hover:bg-brand-hover px-4 py-2 rounded-lg">
  Action principale
</button>

Modifier --brand-primary via JavaScript met instantanément à jour chaque élément avec bg-brand — aucun re-rendu nécessaire.

Solution 2 : styles en ligne pour les couleurs dynamiques uniques

Quand un composant a besoin d'une couleur d'exécution vraiment unique (comme une pastille de couleur dans un visualiseur de palette), utilisez un style en ligne. La syntaxe de valeur arbitraire de Tailwind bg-[#FF5733] fonctionne pour les valeurs statiques connues au moment de la compilation, mais pas pour bg-[${dynamicHex}] dans un template literal en JSX — la classe n'existera pas dans la feuille de style.

// Correct pour les couleurs vraiment dynamiques :
<div
  style={{ backgroundColor: swatch.hex }}
  className="w-8 h-8 rounded"
  aria-label={swatch.name}
/>

// Ceci NE fonctionnera PAS à l'exécution (classe absente de la feuille de style) :
<div className={`bg-[${swatch.hex}]`} /> // Incorrect

Persistance des préférences de couleur de l'utilisateur

localStorage pour la persistance entre sessions

Le ThemeContext ci-dessus écrit déjà dans localStorage dans le useEffect. Le pattern clé pour la compatibilité SSR est d'initialiser l'état de manière paresseuse — en passant une fonction à useState qui lit localStorage uniquement côté client, jamais pendant le rendu serveur :

const [state, setState] = useState<ThemeState>(() => {
  // Cette fonction ne s'exécute que côté client, après l'hydratation
  if (typeof window === 'undefined') {
    return { brandHex: '#2563EB', mode: 'system' };
  }
  try {
    const stored = localStorage.getItem(STORAGE_KEY);
    return stored ? JSON.parse(stored) : { brandHex: '#2563EB', mode: 'system' };
  } catch {
    return { brandHex: '#2563EB', mode: 'system' };
  }
});

Prévenir le flash du thème par défaut au chargement

Pour les applications rendues côté serveur (Next.js App Router), l'arbre de composants est rendu sur le serveur avant d'atteindre le navigateur. Si vous initialisez avec le thème par défaut, les utilisateurs qui ont sauvegardé un thème personnalisé verront un flash — le bleu par défaut apparaît brièvement avant que le violet persisté ne se charge.

La solution est un script en ligne bloquant dans <head> qui applique le thème sauvegardé avant que le navigateur ne rende le moindre pixel :

// app/layout.tsx (Next.js App Router)
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="fr" suppressHydrationWarning>
      <head>
        <script
          dangerouslySetInnerHTML={{
            __html: `
              (function() {
                try {
                  var stored = JSON.parse(localStorage.getItem('app-theme') || '{}');
                  var brand = stored.brandHex || '#2563EB';
                  var mode = stored.mode || 'system';
                  var resolvedMode = mode === 'system'
                    ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
                    : mode;
                  document.documentElement.setAttribute('data-theme', resolvedMode);
                  // Tokens en ligne minimaux pour prévenir le flash
                  document.documentElement.style.setProperty('--brand-primary', brand);
                } catch(e) {}
              })();
            `,
          }}
        />
      </head>
      <body>
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  );
}

Le script en ligne applique uniquement les tokens les plus critiques (couleur de marque, attribut de mode thème) de manière synchrone. L'ensemble complet de tokens est appliqué par le ThemeProvider après l'hydratation — mais comme les tokens visuels dominants sont déjà corrects, les utilisateurs ne voient aucun flash.

Persistance côté serveur via les cookies

Pour les applications authentifiées, stocker le thème dans un cookie (lisible par le serveur) permet le SSR avec le thème correct dès le premier octet :

// middleware.ts (Next.js)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  const brandHex = request.cookies.get('brand-hex')?.value ?? '#2563EB';
  const mode = request.cookies.get('theme-mode')?.value ?? 'system';

  // Transmettre le thème à la page via les en-têtes de réponse ou les paramètres de recherche
  const response = NextResponse.next();
  response.headers.set('x-brand-hex', brandHex);
  response.headers.set('x-theme-mode', mode);
  return response;
}

Lisez les en-têtes dans votre composant serveur racine layout.tsx et passez le thème initial au ThemeProvider comme prop. Le fournisseur s'initialise avec le thème connu du serveur, éliminant complètement le flash.


Génération de variantes accessibles pour le mode sombre

Quand vous générez un thème clair à partir d'une couleur de marque, vous avez également besoin d'une version pour le mode sombre. Le défi : le même hexadécimal de marque qui fonctionne bien sur un fond clair — par exemple #2563EB — échoue souvent aux exigences de contraste WCAG sur des surfaces sombres.

Une approche robuste génère deux ensembles de tokens — un pour le mode clair, un pour le mode sombre — à partir de la même entrée de marque :

function generateDarkTokens(brandHex) {
  const brand = chroma(brandHex);
  const hsl = brand.hsl();

  // Pour le mode sombre, éclaircir la marque pour assurer le contraste sur les surfaces sombres
  const darkBrand = chroma.hsl(hsl[0], hsl[1] * 0.9, Math.max(0.55, hsl[2] + 0.15));

  return {
    '[data-theme="dark"]': {
      '--brand-primary': darkBrand.hex(),
      '--brand-hover': darkBrand.lighten(0.3).hex(),
      '--brand-subtle': darkBrand.darken(2).desaturate(0.5).hex(),
      '--on-brand': '#000000', // Souvent noir sur marque éclaircie en mode sombre
      '--bg-base': '#0F172A',
      '--bg-surface': '#1E293B',
      '--text-primary': '#F1F5F9',
      '--text-secondary': '#94A3B8',
    },
  };
}

Appliquez les tokens sombres via le pattern d'attribut data-theme="dark". Quand le ThemeProvider définit document.documentElement.setAttribute('data-theme', 'dark'), la cascade CSS applique les valeurs de tokens du mode sombre.

Pour vérifier qu'une paire de couleurs générée respecte le WCAG, utilisez le Générateur de palette pour visualiser la gamme complète et vérifier les ratios de contraste entre les étapes.


Points clés à retenir

  • Les propriétés CSS personnalisées sont la fondation — des tokens sémantiques nommés par rôle, pas par valeur de couleur, permettent des changements de thème complets sans modifier les composants.
  • La génération de tokens à l'exécution à partir d'un seul hexadécimal de marque nécessite : une gamme de luminosité/obscurité, une vérification du contraste WCAG pour le texte sur la marque, et un calcul séparé pour les variantes du mode sombre.
  • Le contexte React gère l'état du thème (hexadécimal de marque actif, mode clair/sombre) et applique les tokens au DOM comme effet de bord — les composants n'ont jamais besoin de s'abonner au contexte de thème pour appliquer des couleurs.
  • Antirebondissez les calculs de couleur coûteux (150ms) lorsqu'ils sont connectés à des entrées continues comme des sélecteurs de couleur ou des curseurs.
  • Pour Tailwind CSS : définissez un pont de variables CSS dans @theme pour que les utilitaires bg-brand reflètent la valeur du token à l'exécution ; utilisez les styles en ligne uniquement pour les couleurs dynamiques vraiment uniques par élément.
  • Prévenez le flash du thème par défaut avec un script bloquant synchrone dans <head> qui applique les tokens les plus critiques avant le rendu.
  • Pour les applications SSR avec des thèmes par utilisateur, stockez le thème dans un cookie afin que le serveur puisse rendre le HTML initial correct.
  • Utilisez le Générateur de nuances pour inspecter et valider les gammes de couleurs générées par rapport à la plage complète 50–950, et le Générateur de palette pour vérifier l'harmonie et les ratios de contraste sur l'ensemble de la palette du thème.

Couleurs associées

Marques associées

Outils associés