Skip to content

🎨 Système de Thèmes ​

Vue d'ensemble ​

Mon portfolio supporte 3 modes de thème :

  • System - Suit les préférences système (prefers-color-scheme)
  • Light - Mode clair
  • Dark - Mode sombre

Le système utilise des CSS variables dynamiques et une classe .dark sur <html>.


Architecture ​

CSS Variables ​

Fichier : public/themes/themes.css

css
:root {
  /* Light Mode (par défaut) */
  --color-fill: 255, 255, 255;           /* Blanc */
  --color-fill-inverted: 30, 41, 59;     /* Texte inversé */
  --color-text-base: 30, 41, 59;         /* Texte principal */
  --muted: 148, 163, 184;                /* Texte secondaire */
  --color: 79, 70, 229;                  /* Couleur primaire (indigo) */

  --skin-contact: 79, 70, 229;           /* Icônes de contact */
  --skin-contact-inverted: 255, 255, 255;
  --skin-button-accent: 226, 232, 240;   /* Boutons */
  --skin-button-muted: 241, 245, 249;
}

.dark {
  /* Dark Mode */
  --color-fill: 17, 24, 39;              /* Gris foncé */
  --color-fill-inverted: 226, 232, 240;  /* Texte inversé */
  --color-text-base: 226, 232, 240;      /* Texte clair */
  --muted: 148, 163, 184;                /* Texte secondaire */
  --color: 129, 140, 248;                /* Accent lumineux */

  --skin-contact: 129, 140, 248;
  --skin-contact-inverted: 17, 24, 39;
  --skin-button-accent: 31, 41, 55;
  --skin-button-muted: 55, 65, 81;
}

Format RGB sans rgb() : J'utilise des valeurs RGB séparées (ex: 255, 255, 255) pour pouvoir ajouter de l'opacité avec Tailwind :

html
<!-- bg-skin-fill avec opacité -->
<div class="bg-skin-fill/80">  <!-- 80% d'opacité -->

Intégration avec Tailwind ​

Configuration ​

Fichier : tailwind.config.mjs

javascript
export default {
  theme: {
    extend: {
      colors: {
        skin: {
          // Couleurs de base
          fill: "rgb(var(--color-fill))",
          base: "rgb(var(--color-text-base))",
          inverted: "rgb(var(--color-fill-inverted))",
          muted: "rgb(var(--muted))",

          // Couleur d'accent
          hue: "rgb(var(--color))",

          // Contacts
          contact: "rgb(var(--skin-contact))",
          contactInverted: "rgb(var(--skin-contact-inverted))",

          // Boutons
          "button-accent": "rgb(var(--skin-button-accent))",
          "button-muted": "rgb(var(--skin-button-muted))",
        }
      }
    }
  }
}

Utilisation dans les Composants ​

html
<!-- Fond adaptatif light/dark -->
<div class="bg-skin-fill text-skin-base">
  Contenu qui s'adapte automatiquement
</div>

<!-- Bouton avec couleur primaire -->
<button class="bg-skin-hue hover:bg-skin-hue/80 text-skin-inverted">
  Cliquez ici
</button>

<!-- Texte secondaire -->
<p class="text-skin-muted">
  Informations secondaires
</p>

<!-- Avec opacité -->
<div class="bg-skin-button-accent/50">
  Fond semi-transparent
</div>

Gestion du Thème (theme.ts) ​

Type Theme ​

typescript
export type Theme = "system" | "light" | "dark";

Fonctions Exportées ​

getSystemTheme() ​

Détecte le thème système via prefers-color-scheme :

typescript
export function getSystemTheme(): "dark" | "light" {
  if (
    window.matchMedia &&
    window.matchMedia("(prefers-color-scheme: dark)").matches
  ) {
    return "dark";
  }
  return "light";
}

Utilisation :

typescript
const systemTheme = getSystemTheme();
// → "dark" ou "light" selon les préférences OS

getStoredTheme() ​

Récupère le thème stocké dans localStorage :

typescript
export function getStoredTheme(): Theme | null {
  const stored = localStorage.getItem("theme");
  if (stored && ["system", "light", "dark"].includes(stored)) {
    return stored as Theme;
  }
  return null;
}

Utilisation :

typescript
const stored = getStoredTheme();
if (stored) {
  // L'utilisateur a déjà choisi un thème
  console.log(`Thème stocké: ${stored}`);
} else {
  // 1ère visite
  console.log("Aucun thème stocké");
}

updateTheme(value) ​

Applique un thème et le sauvegarde :

typescript
export function updateTheme(value: Theme): void {
  localStorage.setItem("theme", value);

  const html = document.documentElement;

  if (value === "system") {
    const systemTheme = getSystemTheme();
    html.classList.toggle("dark", systemTheme === "dark");
  } else {
    html.classList.toggle("dark", value === "dark");
  }
}

Ce qui se passe :

  1. Sauvegarde dans localStorage
  2. Récupère l'élément <html>
  3. Ajoute ou retire la classe .dark selon le thème

Exemple :

typescript
updateTheme("dark");
// → <html class="dark">
// → localStorage["theme"] = "dark"

updateTheme("light");
// → <html> (pas de classe)
// → localStorage["theme"] = "light"

updateTheme("system");
// → Vérifie prefers-color-scheme
// → Applique dark ou light en conséquence

initializeTheme(selectElement) ​

Initialise le thème au chargement de la page :

typescript
export function initializeTheme(selectElement: HTMLSelectElement): void {
  const stored = getStoredTheme();

  if (stored) {
    selectElement.value = stored;
    updateTheme(stored);
  } else {
    selectElement.value = "system";
    updateTheme("system");
  }
}

Workflow :

Page Load
    ↓
Check localStorage["theme"]
    ↓
┌─────────────┬──────────────┐
│ Existe      │ N'existe pas │
└─────────────┴──────────────┘
    ↓                ↓
Utilise la      Utilise "system"
valeur stockée      par défaut
    ↓                ↓
updateTheme()   updateTheme()

setupPrintHandlers(selectElement) ​

Configure les handlers pour l'impression :

typescript
export function setupPrintHandlers(selectElement: HTMLSelectElement): void {
  let previousTheme: Theme | null = null;

  window.addEventListener("beforeprint", () => {
    previousTheme = selectElement.value as Theme;
    updateTheme("light");  // Force light mode pour l'impression
  });

  window.addEventListener("afterprint", () => {
    if (previousTheme) {
      updateTheme(previousTheme);  // Restore le thème précédent
    }
  });
}

Raison : Le mode dark n'est pas optimal pour l'impression (gaspillage d'encre).

Workflow :

User appuie sur Cmd+P
        ↓
beforeprint event
        ↓
Sauvegarde le thème actuel
        ↓
Force light mode
        ↓
Impression avec fond blanc
        ↓
afterprint event
        ↓
Restore le thème original

Composants de Sélection ​

ThemeSwitch.astro ​

astro
---
import { t } from "@/utils/i18n";

const { locale } = Astro.props;

const labelText = t("common:theme.label", { lng: locale });
const systemText = t("common:theme.system", { lng: locale });
const lightText = t("common:theme.light", { lng: locale });
const darkText = t("common:theme.dark", { lng: locale });
---

<div>
  <label for="themeSwitch">{labelText}</label>
  <select id="themeSwitch">
    <option value="system">{systemText}</option>
    <option value="light">{lightText}</option>
    <option value="dark">{darkText}</option>
  </select>
</div>

<script>
  import { initializeTheme, updateTheme } from "@/utils/theme";
  import type { Theme } from "@/utils/theme";

  const select = document.getElementById("themeSwitch") as HTMLSelectElement;

  if (select) {
    // Initialise au chargement
    initializeTheme(select);

    // Écoute les changements
    select.addEventListener("change", (e: Event) => {
      const target = e.target as HTMLSelectElement;
      updateTheme(target.value as Theme);
    });
  }
</script>

Hero.astro (aussi responsable du thème) ​

astro
<script>
  import {
    initializeTheme,
    updateTheme,
    setupPrintHandlers
  } from "@/utils/theme";
  import type { Theme } from "@/utils/theme";

  const select = document.getElementById("themeSwitch") as HTMLSelectElement;

  if (select) {
    initializeTheme(select);
    setupPrintHandlers(select);

    select.addEventListener("change", (event: Event) => {
      updateTheme((event.target as HTMLSelectElement).value as Theme);
    });
  }
</script>

Note : Le script est dupliqué dans Hero et ThemeSwitch pour s'assurer que le thème fonctionne même si un des composants n'est pas rendu.


Cas d'Usage ​

1. Première Visite ​

User visite le site (1ère fois)
        ↓
localStorage["theme"] n'existe pas
        ↓
initializeTheme() utilise "system"
        ↓
getSystemTheme() vérifie prefers-color-scheme
        ↓
Si OS en dark mode → Applique .dark
Si OS en light mode → Pas de classe

2. Changement Manuel ​

User change le select à "dark"
        ↓
Event "change"
        ↓
updateTheme("dark")
        ↓
localStorage["theme"] = "dark"
        ↓
<html class="dark">
        ↓
CSS variables du .dark s'appliquent

3. Visite Suivante ​

User revient sur le site
        ↓
localStorage["theme"] = "dark" (existe)
        ↓
initializeTheme() utilise la valeur stockée
        ↓
updateTheme("dark")
        ↓
<html class="dark">
        ↓
Thème restauré automatiquement

4. Mode System avec Changement OS ​

User a choisi "system"
        ↓
OS passe en dark mode
        ↓
prefers-color-scheme: dark
        ↓
Page rechargée ou système écoute le changement
        ↓
getSystemTheme() retourne "dark"
        ↓
<html class="dark">

Note : Actuellement, le changement OS en temps réel n'est pas géré. Il faut recharger la page.


Ajout d'un Nouveau Thème Custom ​

1. Définir les CSS Variables ​

css
/* public/themes/themes.css */
.my-custom-theme {
  --color-fill: 240, 240, 255;        /* Fond bleuté */
  --color-text-base: 20, 20, 40;      /* Texte bleu foncé */
  --color: 100, 100, 255;             /* Accent bleu */
  /* ... autres variables */
}

2. Mettre à Jour le Type ​

typescript
// src/utils/theme.ts
export type Theme = "system" | "light" | "dark" | "my-custom";

3. Mettre à Jour updateTheme ​

typescript
export function updateTheme(value: Theme): void {
  localStorage.setItem("theme", value);
  const html = document.documentElement;

  // Retire toutes les classes de thème
  html.classList.remove("dark", "my-custom-theme");

  if (value === "system") {
    const systemTheme = getSystemTheme();
    html.classList.toggle("dark", systemTheme === "dark");
  } else if (value === "dark") {
    html.classList.add("dark");
  } else if (value === "my-custom") {
    html.classList.add("my-custom-theme");
  }
  // "light" = pas de classe
}

4. Ajouter dans ThemeSwitch ​

astro
<select id="themeSwitch">
  <option value="system">System</option>
  <option value="light">Light</option>
  <option value="dark">Dark</option>
  <option value="my-custom">My Custom</option>
</select>

5. Ajouter les Traductions ​

json
// public/locales/fr/common.json
{
  "theme": {
    "label": "Thème",
    "system": "Système",
    "light": "Clair",
    "dark": "Sombre",
    "myCustom": "Mon Thème"
  }
}

Debugging ​

Vérifier le Thème Actuel ​

javascript
// Dans la console du navigateur
localStorage.getItem("theme")
// → "light" | "dark" | "system" | null

document.documentElement.className
// → "" (light) | "dark" (dark)

Forcer un Thème ​

javascript
// Dans la console
import { updateTheme } from "@/utils/theme";
updateTheme("dark");  // Force dark mode

Tester prefers-color-scheme ​

javascript
window.matchMedia("(prefers-color-scheme: dark)").matches
// → true (OS en dark) | false (OS en light)

Bonnes Pratiques ​

✅ Toujours utiliser les classes skin-* ​

html
<!-- ✅ Bon : S'adapte automatiquement -->
<div class="bg-skin-fill text-skin-base">

<!-- ❌ Mauvais : Couleurs hardcodées -->
<div class="bg-white text-gray-900 dark:bg-gray-900 dark:text-white">

✅ Tester les deux modes ​

Avant de déployer, je vérifie que tous les composants sont lisibles en light ET dark mode.

✅ Utiliser des couleurs sémantiques ​

css
/* ✅ Bon : Noms sémantiques */
--color-fill: ...;        /* Fond */
--color-text-base: ...;   /* Texte */

/* ❌ Mauvais : Noms de couleurs */
--white: ...;
--black: ...;