Implementation d'un Mode Sombre avec Vue.js et la Composition API

Creez un mode sombre elegant avec Vue.js et les composables. Utilisez ref, les CSS variables et localStorage pour persister le theme.

Mahmoud DEVO
Mahmoud DEVO
December 28, 2025 11 min read
Implementation d'un Mode Sombre avec Vue.js et la Composition API

Implementation d’un Mode Sombre avec Vue.js et la Composition API

Le mode sombre est devenu une fonctionnalite incontournable des applications web modernes. Dans ce guide complet, nous allons explorer comment implementer un systeme de themes robuste avec Vue.js 3 et la Composition API, en utilisant les CSS variables, localStorage et la detection automatique des preferences systeme.

Pourquoi le Mode Sombre est Essentiel

Accessibilite et Confort Visuel

Le mode sombre n’est pas qu’une simple preference esthetique. Il repond a des besoins reels des utilisateurs :

  • Reduction de la fatigue oculaire : Les ecrans lumineux dans des environnements sombres peuvent causer une fatigue importante
  • Meilleure lisibilite : Pour certains utilisateurs, notamment ceux souffrant de photophobie ou de migraines, le mode sombre est une necessite
  • Utilisation nocturne : Une interface sombre perturbe moins le cycle circadien lors d’une utilisation tardive

Economie d’Energie

Sur les ecrans OLED et AMOLED, le mode sombre peut reduire significativement la consommation d’energie :

// Les ecrans OLED eteignent physiquement les pixels noirs
// Couleurs recommandees pour economiser l'energie
const darkThemeColors = {
  background: '#000000',     // Noir pur = pixels eteints sur OLED
  surface: '#121212',        // Surface legere
  text: '#E1E1E1',          // Texte visible sans etre eblouissant
  textSecondary: '#A1A1A1', // Texte secondaire
}

Preferences Utilisateur

Les statistiques montrent que 70% a 80% des utilisateurs preferent utiliser le mode sombre lorsqu’il est disponible, particulierement sur mobile.

Le Composable useDarkMode()

Version de Base

Commencons par creer un composable simple pour gerer le mode sombre :

// src/composables/useDarkMode.ts
import { ref, watch } from 'vue'

const isDarkMode = ref(false)

export function useDarkMode() {
  const toggleDarkMode = () => {
    isDarkMode.value = !isDarkMode.value
  }

  const setDarkMode = (value: boolean) => {
    isDarkMode.value = value
  }

  return {
    isDarkMode,
    toggleDarkMode,
    setDarkMode,
  }
}

Cette version basique fonctionne, mais elle a plusieurs limitations :

  • Le theme n’est pas persiste entre les sessions
  • Les preferences systeme ne sont pas detectees
  • Le changement de theme n’est pas applique au DOM

Version Amelioree avec Persistance

Voici une version plus complete qui gere la persistance et l’application au DOM :

// src/composables/useDarkMode.ts
import { ref, watch, onMounted, computed } from 'vue'

// Etat global partage entre tous les composants
const isDarkMode = ref<boolean>(false)
const isInitialized = ref(false)

// Cle pour localStorage
const STORAGE_KEY = 'dark-mode-preference'

export function useDarkMode() {
  // Appliquer la classe au document
  const applyTheme = (dark: boolean) => {
    if (typeof document !== 'undefined') {
      if (dark) {
        document.documentElement.classList.add('dark')
        document.documentElement.setAttribute('data-theme', 'dark')
      } else {
        document.documentElement.classList.remove('dark')
        document.documentElement.setAttribute('data-theme', 'light')
      }
    }
  }

  // Sauvegarder dans localStorage
  const savePreference = (dark: boolean) => {
    if (typeof localStorage !== 'undefined') {
      localStorage.setItem(STORAGE_KEY, JSON.stringify(dark))
    }
  }

  // Charger depuis localStorage
  const loadPreference = (): boolean | null => {
    if (typeof localStorage !== 'undefined') {
      const stored = localStorage.getItem(STORAGE_KEY)
      if (stored !== null) {
        return JSON.parse(stored)
      }
    }
    return null
  }

  // Initialisation
  const initialize = () => {
    if (isInitialized.value) return

    const stored = loadPreference()

    if (stored !== null) {
      // Utiliser la preference sauvegardee
      isDarkMode.value = stored
    } else {
      // Detecter la preference systeme
      if (typeof window !== 'undefined' && window.matchMedia) {
        isDarkMode.value = window.matchMedia('(prefers-color-scheme: dark)').matches
      }
    }

    applyTheme(isDarkMode.value)
    isInitialized.value = true
  }

  // Toggle le mode sombre
  const toggleDarkMode = () => {
    isDarkMode.value = !isDarkMode.value
  }

  // Definir explicitement
  const setDarkMode = (value: boolean) => {
    isDarkMode.value = value
  }

  // Observer les changements
  watch(isDarkMode, (newValue) => {
    applyTheme(newValue)
    savePreference(newValue)
  })

  // Initialiser au montage
  onMounted(() => {
    initialize()
  })

  return {
    isDarkMode,
    toggleDarkMode,
    setDarkMode,
    initialize,
  }
}

Les CSS Variables pour les Themes

Definition des Variables

Les CSS variables (custom properties) sont la fondation d’un systeme de themes flexible :

/* src/styles/themes.css */

:root {
  /* Theme clair (par defaut) */
  --color-background: #ffffff;
  --color-background-secondary: #f5f5f5;
  --color-surface: #ffffff;
  --color-surface-elevated: #ffffff;

  --color-text-primary: #1a1a1a;
  --color-text-secondary: #666666;
  --color-text-muted: #999999;

  --color-border: #e0e0e0;
  --color-border-light: #f0f0f0;

  --color-primary: #3b82f6;
  --color-primary-hover: #2563eb;
  --color-primary-light: #eff6ff;

  --color-success: #22c55e;
  --color-warning: #f59e0b;
  --color-error: #ef4444;

  --color-shadow: rgba(0, 0, 0, 0.1);
  --color-overlay: rgba(0, 0, 0, 0.5);

  /* Typographie */
  --font-family-sans: 'Inter', system-ui, sans-serif;
  --font-family-mono: 'Fira Code', monospace;

  /* Espacements */
  --spacing-xs: 0.25rem;
  --spacing-sm: 0.5rem;
  --spacing-md: 1rem;
  --spacing-lg: 1.5rem;
  --spacing-xl: 2rem;

  /* Rayons de bordure */
  --radius-sm: 0.25rem;
  --radius-md: 0.5rem;
  --radius-lg: 1rem;
  --radius-full: 9999px;

  /* Transitions */
  --transition-fast: 150ms ease;
  --transition-normal: 300ms ease;
  --transition-slow: 500ms ease;
}

/* Theme sombre */
.dark,
[data-theme="dark"] {
  --color-background: #0f0f0f;
  --color-background-secondary: #1a1a1a;
  --color-surface: #1f1f1f;
  --color-surface-elevated: #2a2a2a;

  --color-text-primary: #f5f5f5;
  --color-text-secondary: #a0a0a0;
  --color-text-muted: #666666;

  --color-border: #333333;
  --color-border-light: #2a2a2a;

  --color-primary: #60a5fa;
  --color-primary-hover: #3b82f6;
  --color-primary-light: #1e3a5f;

  --color-shadow: rgba(0, 0, 0, 0.3);
  --color-overlay: rgba(0, 0, 0, 0.7);
}

Tableau des CSS Variables Recommandees

VariableTheme ClairTheme SombreUsage
--color-background#ffffff#0f0f0fFond principal
--color-surface#ffffff#1f1f1fCartes, modales
--color-text-primary#1a1a1a#f5f5f5Texte principal
--color-text-secondary#666666#a0a0a0Texte secondaire
--color-border#e0e0e0#333333Bordures
--color-primary#3b82f6#60a5faCouleur d’accent
--color-shadowrgba(0,0,0,0.1)rgba(0,0,0,0.3)Ombres

Utilisation dans les Composants

<template>
  <div class="card">
    <h2 class="card-title">{{ title }}</h2>
    <p class="card-content">{{ content }}</p>
  </div>
</template>

<style scoped>
.card {
  background-color: var(--color-surface);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  padding: var(--spacing-lg);
  box-shadow: 0 2px 8px var(--color-shadow);
  transition: background-color var(--transition-normal),
              border-color var(--transition-normal);
}

.card-title {
  color: var(--color-text-primary);
  margin-bottom: var(--spacing-sm);
}

.card-content {
  color: var(--color-text-secondary);
}
</style>

Persistance avec localStorage

Gestion Robuste du Stockage

Il est important de gerer les erreurs potentielles lors de l’acces a localStorage :

// src/utils/storage.ts

export interface StorageOptions {
  key: string
  defaultValue?: unknown
  serializer?: {
    read: (value: string) => unknown
    write: (value: unknown) => string
  }
}

const defaultSerializer = {
  read: (value: string) => JSON.parse(value),
  write: (value: unknown) => JSON.stringify(value),
}

export function useLocalStorage<T>(options: StorageOptions) {
  const { key, defaultValue, serializer = defaultSerializer } = options

  const isAvailable = (): boolean => {
    try {
      const testKey = '__storage_test__'
      localStorage.setItem(testKey, testKey)
      localStorage.removeItem(testKey)
      return true
    } catch {
      return false
    }
  }

  const get = (): T | undefined => {
    if (!isAvailable()) return defaultValue as T

    try {
      const item = localStorage.getItem(key)
      if (item === null) return defaultValue as T
      return serializer.read(item) as T
    } catch (error) {
      console.warn(`Erreur lecture localStorage [${key}]:`, error)
      return defaultValue as T
    }
  }

  const set = (value: T): boolean => {
    if (!isAvailable()) return false

    try {
      const serialized = serializer.write(value)
      localStorage.setItem(key, serialized)
      return true
    } catch (error) {
      console.warn(`Erreur ecriture localStorage [${key}]:`, error)
      return false
    }
  }

  const remove = (): boolean => {
    if (!isAvailable()) return false

    try {
      localStorage.removeItem(key)
      return true
    } catch (error) {
      console.warn(`Erreur suppression localStorage [${key}]:`, error)
      return false
    }
  }

  return {
    get,
    set,
    remove,
    isAvailable,
  }
}

Integration avec le Composable

// src/composables/useDarkMode.ts (version amelioree)
import { ref, watch, onMounted } from 'vue'
import { useLocalStorage } from '@/utils/storage'

const isDarkMode = ref(false)
const isInitialized = ref(false)

const storage = useLocalStorage<boolean>({
  key: 'user-theme-preference',
  defaultValue: false,
})

export function useDarkMode() {
  const applyTheme = (dark: boolean) => {
    if (typeof document === 'undefined') return

    document.documentElement.classList.toggle('dark', dark)
    document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light')

    // Mettre a jour la meta theme-color pour le navigateur
    const metaThemeColor = document.querySelector('meta[name="theme-color"]')
    if (metaThemeColor) {
      metaThemeColor.setAttribute('content', dark ? '#0f0f0f' : '#ffffff')
    }
  }

  const initialize = () => {
    if (isInitialized.value) return

    // Priorite : localStorage > preference systeme
    const storedValue = storage.get()

    if (storedValue !== undefined) {
      isDarkMode.value = storedValue
    } else {
      // Detecter la preference systeme
      const prefersDark = window.matchMedia('(prefers-color-scheme: dark)')
      isDarkMode.value = prefersDark.matches
    }

    applyTheme(isDarkMode.value)
    isInitialized.value = true
  }

  const toggleDarkMode = () => {
    isDarkMode.value = !isDarkMode.value
  }

  watch(isDarkMode, (newValue) => {
    applyTheme(newValue)
    storage.set(newValue)
  })

  onMounted(initialize)

  return {
    isDarkMode,
    toggleDarkMode,
    initialize,
  }
}

Detection du Theme Systeme (prefers-color-scheme)

Ecouter les Changements en Temps Reel

Les utilisateurs peuvent changer leur preference systeme pendant qu’ils utilisent votre application :

// src/composables/useSystemTheme.ts
import { ref, onMounted, onUnmounted } from 'vue'

export function useSystemTheme() {
  const prefersDark = ref(false)
  let mediaQuery: MediaQueryList | null = null

  const updatePreference = (event: MediaQueryListEvent | MediaQueryList) => {
    prefersDark.value = event.matches
  }

  onMounted(() => {
    if (typeof window === 'undefined') return

    mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
    prefersDark.value = mediaQuery.matches

    // Ecouter les changements
    mediaQuery.addEventListener('change', updatePreference)
  })

  onUnmounted(() => {
    if (mediaQuery) {
      mediaQuery.removeEventListener('change', updatePreference)
    }
  })

  return {
    prefersDark,
  }
}

Integration dans le Composable Principal

// src/composables/useDarkMode.ts (version complete)
import { ref, watch, onMounted, onUnmounted, computed } from 'vue'

type ThemeMode = 'light' | 'dark' | 'system'

const currentMode = ref<ThemeMode>('system')
const isDarkMode = ref(false)
const isInitialized = ref(false)

const STORAGE_KEY = 'theme-mode'

export function useDarkMode() {
  let mediaQuery: MediaQueryList | null = null

  const systemPrefersDark = ref(false)

  // Calculer si le theme actif est sombre
  const computedIsDark = computed(() => {
    if (currentMode.value === 'system') {
      return systemPrefersDark.value
    }
    return currentMode.value === 'dark'
  })

  const applyTheme = () => {
    const dark = computedIsDark.value
    isDarkMode.value = dark

    if (typeof document !== 'undefined') {
      document.documentElement.classList.toggle('dark', dark)
      document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light')
    }
  }

  const handleSystemChange = (event: MediaQueryListEvent) => {
    systemPrefersDark.value = event.matches
    if (currentMode.value === 'system') {
      applyTheme()
    }
  }

  const setMode = (mode: ThemeMode) => {
    currentMode.value = mode
    if (typeof localStorage !== 'undefined') {
      localStorage.setItem(STORAGE_KEY, mode)
    }
    applyTheme()
  }

  const toggleDarkMode = () => {
    if (currentMode.value === 'system') {
      setMode(systemPrefersDark.value ? 'light' : 'dark')
    } else {
      setMode(currentMode.value === 'dark' ? 'light' : 'dark')
    }
  }

  const cycleMode = () => {
    const modes: ThemeMode[] = ['light', 'dark', 'system']
    const currentIndex = modes.indexOf(currentMode.value)
    const nextIndex = (currentIndex + 1) % modes.length
    setMode(modes[nextIndex])
  }

  const initialize = () => {
    if (isInitialized.value) return

    // Detecter la preference systeme
    if (typeof window !== 'undefined' && window.matchMedia) {
      mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
      systemPrefersDark.value = mediaQuery.matches
      mediaQuery.addEventListener('change', handleSystemChange)
    }

    // Charger la preference sauvegardee
    if (typeof localStorage !== 'undefined') {
      const stored = localStorage.getItem(STORAGE_KEY) as ThemeMode | null
      if (stored && ['light', 'dark', 'system'].includes(stored)) {
        currentMode.value = stored
      }
    }

    applyTheme()
    isInitialized.value = true
  }

  onMounted(initialize)

  onUnmounted(() => {
    if (mediaQuery) {
      mediaQuery.removeEventListener('change', handleSystemChange)
    }
  })

  return {
    isDarkMode,
    currentMode,
    toggleDarkMode,
    setMode,
    cycleMode,
    initialize,
  }
}

Transitions CSS entre les Themes

Transitions Fluides

Pour une experience utilisateur agreable, ajoutez des transitions lors du changement de theme :

/* src/styles/transitions.css */

/* Transition globale pour le changement de theme */
html.theme-transition,
html.theme-transition *,
html.theme-transition *::before,
html.theme-transition *::after {
  transition:
    background-color 300ms ease,
    border-color 300ms ease,
    color 200ms ease,
    fill 200ms ease,
    stroke 200ms ease,
    box-shadow 300ms ease !important;
  transition-delay: 0ms !important;
}

/* Animation du bouton de toggle */
.theme-toggle-button {
  position: relative;
  width: 3rem;
  height: 3rem;
  border-radius: var(--radius-full);
  background-color: var(--color-surface);
  border: 1px solid var(--color-border);
  cursor: pointer;
  overflow: hidden;
  transition: transform 150ms ease;
}

.theme-toggle-button:hover {
  transform: scale(1.05);
}

.theme-toggle-button:active {
  transform: scale(0.95);
}

/* Icones soleil et lune */
.theme-toggle-icon {
  position: absolute;
  top: 50%;
  left: 50%;
  width: 1.5rem;
  height: 1.5rem;
  transition:
    transform 500ms cubic-bezier(0.4, 0, 0.2, 1),
    opacity 300ms ease;
}

.sun-icon {
  transform: translate(-50%, -50%) rotate(0deg) scale(1);
  opacity: 1;
}

.moon-icon {
  transform: translate(-50%, -50%) rotate(-90deg) scale(0);
  opacity: 0;
}

.dark .sun-icon {
  transform: translate(-50%, -50%) rotate(90deg) scale(0);
  opacity: 0;
}

.dark .moon-icon {
  transform: translate(-50%, -50%) rotate(0deg) scale(1);
  opacity: 1;
}

Composant ThemeToggle

<!-- src/components/ThemeToggle.vue -->
<template>
  <button
    @click="handleClick"
    class="theme-toggle"
    :aria-label="ariaLabel"
    :title="buttonTitle"
  >
    <span class="theme-toggle-track">
      <!-- Icone Soleil -->
      <svg
        class="theme-icon sun"
        :class="{ active: !isDarkMode }"
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        stroke-width="2"
      >
        <circle cx="12" cy="12" r="5" />
        <line x1="12" y1="1" x2="12" y2="3" />
        <line x1="12" y1="21" x2="12" y2="23" />
        <line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
        <line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
        <line x1="1" y1="12" x2="3" y2="12" />
        <line x1="21" y1="12" x2="23" y2="12" />
        <line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
        <line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
      </svg>

      <!-- Icone Lune -->
      <svg
        class="theme-icon moon"
        :class="{ active: isDarkMode }"
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        stroke-width="2"
      >
        <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
      </svg>
    </span>
  </button>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { useDarkMode } from '@/composables/useDarkMode'

const { isDarkMode, toggleDarkMode } = useDarkMode()

const ariaLabel = computed(() =>
  isDarkMode.value
    ? 'Activer le mode clair'
    : 'Activer le mode sombre'
)

const buttonTitle = computed(() =>
  isDarkMode.value
    ? 'Mode clair'
    : 'Mode sombre'
)

const handleClick = () => {
  // Ajouter la classe de transition
  document.documentElement.classList.add('theme-transition')

  toggleDarkMode()

  // Retirer la classe apres la transition
  setTimeout(() => {
    document.documentElement.classList.remove('theme-transition')
  }, 300)
}
</script>

<style scoped>
.theme-toggle {
  position: relative;
  width: 2.5rem;
  height: 2.5rem;
  border-radius: var(--radius-full);
  background-color: var(--color-surface);
  border: 1px solid var(--color-border);
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  transition:
    background-color var(--transition-fast),
    border-color var(--transition-fast),
    transform var(--transition-fast);
}

.theme-toggle:hover {
  transform: scale(1.1);
  border-color: var(--color-primary);
}

.theme-toggle:focus-visible {
  outline: 2px solid var(--color-primary);
  outline-offset: 2px;
}

.theme-toggle-track {
  position: relative;
  width: 1.25rem;
  height: 1.25rem;
}

.theme-icon {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  color: var(--color-text-primary);
  transition:
    transform 400ms cubic-bezier(0.4, 0, 0.2, 1),
    opacity 250ms ease;
}

.theme-icon.sun {
  transform: rotate(0deg) scale(1);
  opacity: 1;
}

.theme-icon.moon {
  transform: rotate(-90deg) scale(0);
  opacity: 0;
}

.theme-icon.sun:not(.active) {
  transform: rotate(90deg) scale(0);
  opacity: 0;
}

.theme-icon.moon.active {
  transform: rotate(0deg) scale(1);
  opacity: 1;
}
</style>

Provide/Inject pour le Theme Global

Provider de Theme

Pour partager l’etat du theme dans toute l’application, utilisez provide/inject :

// src/providers/ThemeProvider.ts
import { provide, inject, InjectionKey, Ref, ComputedRef } from 'vue'

type ThemeMode = 'light' | 'dark' | 'system'

interface ThemeContext {
  isDarkMode: Ref<boolean>
  currentMode: Ref<ThemeMode>
  toggleDarkMode: () => void
  setMode: (mode: ThemeMode) => void
  cycleMode: () => void
}

export const ThemeKey: InjectionKey<ThemeContext> = Symbol('theme')

export function provideTheme(context: ThemeContext) {
  provide(ThemeKey, context)
}

export function useTheme(): ThemeContext {
  const theme = inject(ThemeKey)

  if (!theme) {
    throw new Error('useTheme() must be used within a ThemeProvider')
  }

  return theme
}

Composant ThemeProvider

<!-- src/components/ThemeProvider.vue -->
<template>
  <slot />
</template>

<script setup lang="ts">
import { useDarkMode } from '@/composables/useDarkMode'
import { provideTheme } from '@/providers/ThemeProvider'

const themeContext = useDarkMode()

provideTheme(themeContext)
</script>

Utilisation dans App.vue

<!-- src/App.vue -->
<template>
  <ThemeProvider>
    <div class="app-container">
      <AppHeader />
      <main class="main-content">
        <RouterView />
      </main>
      <AppFooter />
    </div>
  </ThemeProvider>
</template>

<script setup lang="ts">
import ThemeProvider from '@/components/ThemeProvider.vue'
import AppHeader from '@/components/AppHeader.vue'
import AppFooter from '@/components/AppFooter.vue'
</script>

<style>
.app-container {
  min-height: 100vh;
  background-color: var(--color-background);
  color: var(--color-text-primary);
  transition:
    background-color var(--transition-normal),
    color var(--transition-normal);
}

.main-content {
  max-width: 1200px;
  margin: 0 auto;
  padding: var(--spacing-lg);
}
</style>

Utilisation dans les Composants Enfants

<!-- src/components/ChildComponent.vue -->
<template>
  <div class="child-component">
    <p>Theme actuel : {{ isDarkMode ? 'Sombre' : 'Clair' }}</p>
    <button @click="toggleDarkMode">
      Changer de theme
    </button>
  </div>
</template>

<script setup lang="ts">
import { useTheme } from '@/providers/ThemeProvider'

const { isDarkMode, toggleDarkMode } = useTheme()
</script>

Exemple Complet : Systeme de Themes

Selecteur de Theme Avance

<!-- src/components/ThemeSelector.vue -->
<template>
  <div class="theme-selector" role="radiogroup" aria-label="Selection du theme">
    <button
      v-for="option in themeOptions"
      :key="option.value"
      @click="setMode(option.value)"
      :class="['theme-option', { active: currentMode === option.value }]"
      :aria-pressed="currentMode === option.value"
      :title="option.description"
    >
      <component :is="option.icon" class="theme-option-icon" />
      <span class="theme-option-label">{{ option.label }}</span>
    </button>
  </div>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { useTheme } from '@/providers/ThemeProvider'

// Icones en tant que composants fonctionnels
const SunIcon = {
  template: `
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
      <circle cx="12" cy="12" r="5"/>
      <line x1="12" y1="1" x2="12" y2="3"/>
      <line x1="12" y1="21" x2="12" y2="23"/>
      <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
      <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
      <line x1="1" y1="12" x2="3" y2="12"/>
      <line x1="21" y1="12" x2="23" y2="12"/>
      <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
      <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
    </svg>
  `
}

const MoonIcon = {
  template: `
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
      <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
    </svg>
  `
}

const SystemIcon = {
  template: `
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
      <rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
      <line x1="8" y1="21" x2="16" y2="21"/>
      <line x1="12" y1="17" x2="12" y2="21"/>
    </svg>
  `
}

const { currentMode, setMode } = useTheme()

const themeOptions = [
  {
    value: 'light' as const,
    label: 'Clair',
    description: 'Utiliser le theme clair',
    icon: SunIcon,
  },
  {
    value: 'dark' as const,
    label: 'Sombre',
    description: 'Utiliser le theme sombre',
    icon: MoonIcon,
  },
  {
    value: 'system' as const,
    label: 'Systeme',
    description: 'Suivre les preferences du systeme',
    icon: SystemIcon,
  },
]
</script>

<style scoped>
.theme-selector {
  display: flex;
  gap: var(--spacing-sm);
  padding: var(--spacing-xs);
  background-color: var(--color-background-secondary);
  border-radius: var(--radius-lg);
}

.theme-option {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--spacing-xs);
  padding: var(--spacing-sm) var(--spacing-md);
  border-radius: var(--radius-md);
  border: none;
  background-color: transparent;
  color: var(--color-text-secondary);
  cursor: pointer;
  transition:
    background-color var(--transition-fast),
    color var(--transition-fast);
}

.theme-option:hover {
  background-color: var(--color-surface);
  color: var(--color-text-primary);
}

.theme-option.active {
  background-color: var(--color-primary);
  color: white;
}

.theme-option-icon {
  width: 1.25rem;
  height: 1.25rem;
}

.theme-option-label {
  font-size: 0.75rem;
  font-weight: 500;
}
</style>

Bonnes Pratiques

Voici les recommandations essentielles pour implementer un mode sombre de qualite :

1. Utilisez les CSS Variables

Les CSS variables permettent un changement de theme instantane sans re-render des composants. Definissez toutes vos couleurs comme variables et basculez-les au niveau du :root.

2. Respectez les Preferences Systeme

Detectez toujours prefers-color-scheme comme valeur par defaut. Les utilisateurs apprecient que leur preference soit automatiquement respectee.

3. Persistez le Choix Utilisateur

Une fois que l’utilisateur fait un choix explicite, sauvegardez-le dans localStorage. Sa preference doit etre prioritaire sur celle du systeme.

4. Evitez le Flash de Theme

Le “flash” qui apparait lors du chargement initial est desagreable. Ajoutez un script inline dans le <head> pour appliquer le theme avant le rendu :

<script>
  (function() {
    const stored = localStorage.getItem('theme-mode');
    const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
    const dark = stored === 'dark' || (stored === 'system' || !stored) && prefersDark;
    if (dark) document.documentElement.classList.add('dark');
  })();
</script>

5. Testez l’Accessibilite

Verifiez que les contrastes sont suffisants dans les deux themes. Utilisez des outils comme Lighthouse ou axe pour valider les ratios de contraste (minimum 4.5:1 pour le texte normal).

6. Ajoutez des Transitions Fluides

Les transitions entre themes doivent etre douces mais pas trop longues (200-300ms). Evitez de transitionner les proprietes couteuses comme background-image.

Pieges Courants

1. Ne Pas Gerer le SSR/SSG

En rendu cote serveur, localStorage et window ne sont pas disponibles. Verifiez toujours leur existence :

// Mauvais
const isDark = localStorage.getItem('dark') === 'true'

// Bon
const isDark = typeof localStorage !== 'undefined'
  ? localStorage.getItem('dark') === 'true'
  : false

2. Oublier la Meta theme-color

La <meta name="theme-color"> affecte la barre d’adresse du navigateur mobile. Mettez-la a jour lors du changement de theme.

3. Utiliser des Couleurs en Dur

Evitez d’utiliser des couleurs hexadecimales directement dans les styles. Utilisez toujours les CSS variables pour permettre le theming.

/* Mauvais */
.card {
  background-color: #ffffff;
  color: #333333;
}

/* Bon */
.card {
  background-color: var(--color-surface);
  color: var(--color-text-primary);
}

4. Ignorer les Images et Icones

Les images et icones peuvent necessiter des variantes pour chaque theme. Utilisez des filtres CSS ou des sprites SVG avec currentColor.

5. Ne Pas Ecouter les Changements Systeme

L’utilisateur peut changer sa preference systeme pendant qu’il utilise votre application. Ecoutez l’evenement change sur la media query.

Conclusion

L’implementation d’un mode sombre avec Vue.js et la Composition API est un excellent exercice qui touche a plusieurs aspects du developpement frontend moderne :

  • Gestion d’etat reactif avec les refs et les watchers
  • Composables reutilisables pour la logique metier
  • CSS moderne avec les custom properties
  • Persistance avec localStorage
  • Detection des preferences avec les media queries
  • Injection de dependances avec provide/inject

Le systeme que nous avons construit est :

  • Flexible : supporte trois modes (clair, sombre, systeme)
  • Persistant : memorise les preferences utilisateur
  • Reactif : repond aux changements systeme en temps reel
  • Accessible : utilise les attributs ARIA et les contrastes adequats
  • Performant : transitions CSS fluides sans re-render

N’hesitez pas a adapter ce systeme a vos besoins specifiques. Vous pouvez etendre le concept pour supporter plusieurs themes personnalises, ou integrer des palettes de couleurs dynamiques.

Exercice pratique : Implementez un systeme de themes multiples (pas seulement clair/sombre) avec une palette de couleurs configurable par l’utilisateur. Utilisez les concepts appris dans cet article pour creer un selecteur de theme complet avec preview en temps reel.


Cet article fait partie de notre serie sur Vue.js et la Composition API. Decouvrez nos autres articles pour approfondir vos connaissances sur le developpement d’applications Vue.js modernes.

Advertisement

In-Article Ad

Dev Mode

Share this article

Mahmoud DEVO

Mahmoud DEVO

Senior Full-Stack Developer

I'm a passionate full-stack developer with 10+ years of experience building scalable web applications. I write about Vue.js, Node.js, PostgreSQL, and modern DevOps practices.

Enjoyed this article?

Subscribe to get more tech content delivered to your inbox.

Related Articles