Thèmes et mode sombre

Câbler le thème clair, sombre et automatique sans flash au chargement, et construire une bascule.

Le mode sombre repose sur une classe dark posée sur <html>. La source de vérité est cette classe, pas un état React, pour que le thème soit correct avant l'hydratation. Pour changer les couleurs elles mêmes, voir Personnaliser l'apparence.

Câblage

Deux pièces, à des endroits différents.

// app/layout.js
import { LrvoProvider } from '@lrvo/core/features/provider';
import { ThemeStyle } from '@lrvo/core/themes/server';
import '@lrvo/core/styles/core.css';

export default function RootLayout({ children }) {
  return (
    <html lang="fr" suppressHydrationWarning>
      <head>
        <ThemeStyle />
      </head>
      <body>
        <LrvoProvider>{children}</LrvoProvider>
      </body>
    </html>
  );
}

<ThemeStyle /> va dans le <head>, LrvoProvider dans le <body>. Les deux sont nécessaires.

Ce que fait chaque pièce

<ThemeStyle /> injecte les jetons du thème du projet et un script qui s'exécute avant le premier rendu. Le script lit localStorage et pose la classe dark immédiatement. Sans lui, la page s'affiche en clair puis bascule en sombre après l'hydratation, ce qui produit un flash visible.

LrvoProvider monte ThemeWatcher, qui suit prefers-color-scheme. Quand aucune préférence n'est stockée, un changement de thème système est répercuté sans rechargement.

ThemeWatcher rattrape aussi le cas où le script n'a pas tourné. Une page qui appelle notFound() est rendue entièrement côté client par Next.js, et un script inséré par React n'est jamais exécuté : sans ce rattrapage, une page 404 resterait en clair. Voir ThemeWatcher.

Le câblage manuel reste possible, mais monter ThemeWatcher en plus de LrvoProvider le dupliquerait.

Construire une bascule

'use client';
import { useTheme, getThemeIcon } from '@lrvo/core/themes';

export function ThemeToggle() {
  const { theme, toggle, canToggle } = useTheme();
  if (!canToggle) return null;
  const Icon = getThemeIcon(theme);

  return (
    <button onClick={toggle} title="Changer de thème">
      <Icon width={18} height={18} />
    </button>
  );
}

L'admin fournit déjà ThemeToggle dans @lrvo/core/features/admin/components.

canToggle vaut false quand le thème du projet n'offre qu'un mode (modes: ['light']). La bascule ne doit alors pas s'afficher : elle ne changerait rien.

Le cycle

Trois états : light, dark, auto. L'ordre dépend de la préférence système, pour que le premier appui produise toujours un changement visible.

SystèmeCycle
Clairauto → dark → light → auto
Sombreauto → light → dark → auto

Sans cette adaptation, un utilisateur en système sombre qui part de auto et bascule vers dark ne verrait rien changer.

Icônes

ThèmeIcône
lightSun01Icon
darkMoon02Icon
autoComputerIcon

Appliquer un thème par programme

import { applyTheme, getStoredTheme } from '@lrvo/core/themes';

applyTheme('dark');   // pose la classe, stocke 'dark'
applyTheme('auto');   // retire la valeur stockée, suit le système

applyTheme('auto') supprime la préférence plutôt que d'enregistrer 'auto'. C'est ce qui permet à ThemeWatcher de reprendre la main.

Écrire du CSS thémé

Le mode sombre est porté par les jetons, pas par la variante dark:. Un composant écrit un rôle, et le jeton change de valeur quand la classe dark bascule :

<div className="bg-surface text-fg border border-line">

Écrire bg-white dark:bg-neutral-950 à la place figerait la couleur hors du thème du projet, et la variante sombre finit toujours par manquer quelque part. npm run check:colors refuse d'ailleurs ces classes dans le core et les modules. Le variant dark: reste utile pour ce qui n'est pas une couleur, comme un logo propre au sombre (dark:hidden, hidden dark:block).

Les règles de rendu sont dans le design system : le thème sombre est une inversion calibrée, pas une nouvelle identité.

Pièges

Ne pas rendre l'icône de bascule côté serveur. Le serveur ne connaît pas localStorage : le rendu initial afficherait toujours la même icône, corrigée à l'hydratation. useTheme étant client, le composant qui l'utilise porte 'use client'.

Ne pas dupliquer ThemeWatcher. LrvoProvider le monte déjà.

Ne pas stocker le thème côté serveur. Le thème est une préférence de navigateur, pas de compte. Le lier à l'utilisateur imposerait un aller-retour serveur avant le premier rendu, donc un flash.

Vérifier les deux thèmes. Un jeton mal choisi, fg à la place de primary par exemple, ne se voit pas en noir et blanc mais saute aux yeux avec une couleur de marque. Le devkit, sous /admin/devkit/components, affiche la galerie complète et permet de contrôler les deux d'un coup.

Sur cette page