<v/>
{ }
Vue 3 · Dark Mode · Design Tokens · Theming
Vue Dark Mode, Theming
und Design Tokens in Vue Apps

Dark Mode mit einem einfachen Klasse-Toggle zu implementieren ist der erste Schritt. Ein professionelles Theme-System mit Design Tokens, System-Präferenz-Erkennung, Persistenz und flimmerfreiem SSR-Laden ist das, was Nutzern in der Praxis tatsächlich zugutekommt. Vue 3 bietet alle Bausteine – es geht darum, sie richtig zusammenzusetzen.

14 Min. Lesezeit CSS Custom Properties · Tailwind · useTheme · Pinia · prefers-color-scheme Vue 3.x · Tailwind CSS v3/v4 · Nuxt 3

1. Was Design Tokens sind und warum sie Dark Mode vereinfachen

Design Tokens sind benannte, semantische Designentscheidungen in Form von Variablen: --color-surface statt #ffffff, --color-text-primary statt #111827, --color-border statt #e5e7eb. Der entscheidende Unterschied zu einfachen Farbwerten: Design Tokens beschreiben die Rolle einer Farbe, nicht ihren visuellen Wert. --color-surface ist im Light Mode weiß und im Dark Mode dunkelgrau – der Name bleibt gleich, der Wert ändert sich je nach Theme.

Ohne Design Tokens sieht Dark-Mode-Implementierung so aus: Jede Komponente hat explizite Dark-Mode-Klassen wie dark:bg-gray-900 dark:text-white. Das funktioniert, skaliert aber schlecht. Bei hundert Komponenten bedeutet eine Theme-Änderung hundert manuelle Anpassungen. Mit Design Tokens als CSS Custom Properties gibt es eine einzige Stelle, an der das Dark-Mode-Theme definiert wird. Alle Komponenten, die --color-surface verwenden, bekommen automatisch den Dark-Mode-Wert. Das ist der fundamentale Vorteil des Token-basierten Ansatzes.

Im Kontext von Vue 3 und Dark Mode sind Design Tokens als CSS Custom Properties implementiert, die auf dem :root-Element oder auf dem html-Element mit einer data-theme- oder class-Selektion überschrieben werden. Das ermöglicht, das Theme durch ein einfaches DOM-Attribut zu wechseln, ohne JavaScript neu zu rendern. Vue reaktiviert nur den State, der das Attribut kontrolliert – der CSS-Wechsel passiert nativ im Browser.

2. CSS Custom Properties als Design-Token-Layer

Die Implementierung von Design Tokens mit CSS Custom Properties beginnt mit einer semantischen Token-Hierarchie. Auf der untersten Ebene liegen primitive Tokens: konkrete Farbwerte wie --green-600: #16a34a. Auf der nächsten Ebene semantische Tokens: --color-primary: var(--green-600). Auf der obersten Ebene komponentenspezifische Tokens: --button-background: var(--color-primary). Diese Hierarchie ermöglicht, das Theme auf der semantischen Ebene zu wechseln, ohne alle primitiven Tokens zu duplizieren.

Das Dark Mode-Theme überschreibt nur die semantischen Tokens. Im :root werden die Light-Mode-Werte definiert. Im [data-theme="dark"] oder .dark-Selektor werden nur die semantischen Tokens überschrieben. Primitive Tokens bleiben unverändert, weil sie keine Bedeutung im Dark-Mode-Kontext haben. Diese Struktur macht Design Token-Verwaltung skalierbar: Neue Farben werden einmal als primitive Tokens definiert und dann in semantische Tokens eingehängt.


/* src/styles/tokens.css */
/* Design Token layer — primitives and semantic tokens */

:root {
  /* Primitive tokens (never use directly in components) */
  --green-50: #f0fdf4;
  --green-600: #16a34a;
  --green-700: #15803d;
  --slate-50: #f8fafc;
  --slate-900: #0f172a;
  --white: #ffffff;

  /* Semantic tokens — Light Mode defaults */
  --color-bg: var(--white);
  --color-surface: var(--slate-50);
  --color-text-primary: var(--slate-900);
  --color-text-muted: #64748b;
  --color-border: #e2e8f0;
  --color-primary: var(--green-600);
  --color-primary-hover: var(--green-700);

  /* Component tokens */
  --card-bg: var(--color-bg);
  --card-border: var(--color-border);
  --nav-bg: var(--color-surface);
}

/* Dark Mode — only override semantic tokens */
[data-theme="dark"] {
  --color-bg: #0f172a;
  --color-surface: #1e293b;
  --color-text-primary: #f1f5f9;
  --color-text-muted: #94a3b8;
  --color-border: #334155;
  /* --color-primary stays green — same brand color in both themes */
}

3. Das useTheme-Composable: Dark Mode in Vue 3 kapseln

Das useTheme-Composable ist das Herzstück des Vue-Dark-Mode-Systems. Es kapselt den gesamten Theme-Lifecycle: System-Präferenz lesen, LocalStorage prüfen, aktives Theme setzen, das data-theme-Attribut auf dem html-Element aktualisieren und auf System-Präferenz-Änderungen reagieren. Komponenten, die das Theme anzeigen oder umschalten wollen, rufen einfach useTheme() auf und bekommen theme, isDark und toggleTheme zurück.

Der DOM-Schreibvorgang – document.documentElement.setAttribute('data-theme', theme.value) – sollte in einem watchEffect passieren, der auf den reaktiven Theme-State reagiert. Das entkoppelt den Vue-State von der DOM-Manipulation: Der Theme-State wird reaktiv verwaltet, der Watch-Effekt synchronisiert den State in den DOM. Das ist sauberer als direktes DOM-Schreiben in Methoden, weil der Watch-Effekt auch beim initialen Composable-Aufruf ausgeführt wird und so das initiale Theme korrekt setzt.

Für den globalen Einsatz – alle Komponenten sollen auf dasselbe Theme zugreifen – wird das Composable entweder als Composable Store (Modulscope-State) oder als Pinia-Store implementiert. Bei SSR-Anwendungen ist der Pinia-Store vorzuziehen. Bei Client-only-Anwendungen reicht der Composable-Store-Ansatz mit Modulscope-State, der das aktive Theme als ref außerhalb der Composable-Funktion hält.


// src/composables/useTheme.ts
import { ref, computed, watchEffect, onMounted } from 'vue'

type Theme = 'light' | 'dark' | 'system'
type ResolvedTheme = 'light' | 'dark'

// Module-scope state — shared across all component instances (client-only)
const activeTheme = ref<Theme>('system')

export function useTheme() {
  const resolvedTheme = computed<ResolvedTheme>(() => {
    if (activeTheme.value === 'system') {
      return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
    }
    return activeTheme.value
  })

  const isDark = computed(() => resolvedTheme.value === 'dark')

  // Sync reactive state to DOM attribute + persist to localStorage
  watchEffect(() => {
    if (typeof document === 'undefined') return  // SSR guard
    document.documentElement.setAttribute('data-theme', resolvedTheme.value)
    localStorage.setItem('theme', activeTheme.value)
  })

  // Read persisted preference after mount (not during SSR)
  onMounted(() => {
    const persisted = localStorage.getItem('theme') as Theme | null
    if (persisted) activeTheme.value = persisted
  })

  function setTheme(theme: Theme) { activeTheme.value = theme }
  function toggleTheme() {
    activeTheme.value = isDark.value ? 'light' : 'dark'
  }

  return { theme: activeTheme, resolvedTheme, isDark, setTheme, toggleTheme }
}

4. System-Präferenz erkennen: prefers-color-scheme und matchMedia

Das Media-Query-Feature prefers-color-scheme ist der Browser-Standard für System-Dark-Mode-Erkennung. window.matchMedia('(prefers-color-scheme: dark)').matches gibt true zurück, wenn das Betriebssystem oder der Browser Dark Mode aktiviert hat. Das ist der Ausgangspunkt für das Dark Mode-Default: Wenn kein gespeichertes Theme vorhanden ist, richtet sich die Anwendung nach der System-Präferenz. Nutzer, die Dark Mode systemmweit aktiviert haben, bekommen ihn automatisch ohne manuelle Einstellung.

Um auf Änderungen der System-Präferenz zu reagieren – der Nutzer wechselt zwischen Dark und Light Mode im Betriebssystem –, registriert das Composable einen addEventListener auf dem matchMedia-Objekt. Das change-Event wird gefeuert, wenn der Nutzer das System-Theme ändert. Im Composable-Cleanup (onUnmounted) wird der Listener wieder entfernt. Solange das aktive Theme 'system' ist, reagiert die Vue Dark Mode-Implementierung auf diese Änderungen live, ohne Neuladen der Seite.

Ein wichtiger UX-Aspekt: Das 'system'-Theme sollte nicht als separater Wert in einem Toggle-Button erscheinen, sondern als implizites Default-Verhalten. Die UX-Empfehlung: Ein Toggle-Button wechselt zwischen 'light' und 'dark'. Ein separater "Zurücksetzen"-Button setzt das Theme auf 'system'. Das entspricht dem Nutzungsverhalten: Die meisten Nutzer wollen entweder explizit Hell oder Dunkel erzwingen oder das System entscheiden lassen.

5. Theme-Persistenz: LocalStorage und Pinia-Integration

Das gewählte Theme muss über Seitenladevorgänge hinweg persistiert werden. Die einfachste Persistenz-Methode: localStorage.setItem('theme', theme) beim Setzen und localStorage.getItem('theme') beim Initialisieren. Das funktioniert für Client-only-Anwendungen zuverlässig. Die Herausforderung bei SSR: localStorage ist auf dem Server nicht verfügbar, und der initiale HTML-Response kennt das Theme des Nutzers nicht – das führt zum Flash-of-Wrong-Theme-Problem.

Für Pinia-basierte Theme-Persistenz eignet sich das Plugin pinia-plugin-persistedstate: Es serialisiert den Theme-State automatisch in LocalStorage und hydratiert ihn beim Start. In Nuxt können Cookies statt LocalStorage verwendet werden, weil der Server bei jedem Request den Cookie lesen und das Theme server-seitig anwenden kann. Das eliminiert den Theme-Flash vollständig, weil der Server bereits das korrekte data-theme-Attribut im HTML rendert.

6. Tailwind CSS Dark Mode: class vs. media Strategy

Tailwind CSS bietet zwei Strategien für Dark Mode: media und class. Die media-Strategie aktiviert Dark-Mode-Varianten automatisch basierend auf dem System-prefers-color-scheme-Media-Query – ohne JavaScript, ohne DOM-Manipulation. Die class-Strategie aktiviert Dark-Mode-Varianten, wenn eine bestimmte Klasse (dark standardmäßig) auf einem Elternelement vorhanden ist. Für Vue-Anwendungen mit manuellem Theme-Toggle und Persistenz ist class die richtige Wahl, weil sie programmatische Kontrolle über das aktive Theme ermöglicht.

Tailwind CSS v4 erlaubt die Kombination von CSS Custom Properties und Dark-Mode-Varianten besonders elegant. Mit @custom-media --dark (prefers-color-scheme: dark) und Token-Definitionen im CSS kann das Design-Token-System direkt in Tailwind-Klassen genutzt werden. Design Tokens als CSS Custom Properties und Tailwind-Dark-Mode-Klassen sind keine Konkurrenten, sondern Ergänzungen: Tokens für konsistente Basisfarben in Komponenten, Tailwind-Dark-Mode-Klassen für präzise komponentenspezifische Anpassungen.


// tailwind.config.ts — class-based dark mode for programmatic control
import type { Config } from 'tailwindcss'

export default {
  // class strategy: dark mode activated by .dark class on html element
  darkMode: ['class', '[data-theme="dark"]'],
  content: ['./index.html', './src/**/*.{vue,ts,tsx}'],
  theme: {
    extend: {
      // Map design tokens to Tailwind utilities
      colors: {
        bg: 'var(--color-bg)',
        surface: 'var(--color-surface)',
        'text-primary': 'var(--color-text-primary)',
        'text-muted': 'var(--color-text-muted)',
        border: 'var(--color-border)',
        primary: 'var(--color-primary)',
      },
    },
  },
} satisfies Config

// Usage in components — semantic token classes instead of hardcoded colors
// bg-bg           → var(--color-bg)       → white / dark: #0f172a
// text-text-primary → var(--color-text-primary) → slate-900 / dark: #f1f5f9
// dark:bg-surface  → also possible for component-specific overrides

7. Mehrere Themes: Beyond Dark und Light

Das Design-Token-System mit CSS Custom Properties skaliert problemlos auf mehr als zwei Themes. Statt light und dark kann ein Theme-System ocean, forest, contrast-high und print unterstützen – jedes Theme überschreibt dieselben semantischen Tokens mit anderen Werten. Der data-theme-Selector-Ansatz ermöglicht beliebig viele Themes ohne Tailwind-Konfigurationsänderung: [data-theme="ocean"], [data-theme="forest"] und so weiter.

Das useTheme-Composable muss dafür nur die Theme-Liste kennen und die Typ-Definition anpassen. Die CSS-Schicht übernimmt die gesamte visuelle Implementierung. Das ermöglicht, Themes ohne JavaScript-Änderungen hinzuzufügen – ein neues CSS-Token-Set für ein neues Theme registrieren, und es steht sofort im Theme-Switcher zur Verfügung. High-Contrast-Themes für Barrierefreiheit folgen demselben Muster und können sogar das prefers-contrast: high-Media-Query als automatisches Aktivierungskriterium nutzen.

8. Das Flash-of-Wrong-Theme-Problem bei SSR lösen

Das Flash-of-Wrong-Theme (FOWT) ist das bekannteste Problem bei Dark Mode mit SSR. Der Server rendert HTML ohne Kenntnis des Nutzer-Themes. Der Browser zeigt kurz das falsche Theme (meist Light Mode), bevor JavaScript das Theme aus LocalStorage liest und das DOM aktualisiert. Das Ergebnis: ein sichtbares Flimmern beim Seitenaufladen, das als professionell mangelhaft wahrgenommen wird.

Die robusteste Lösung für das FOWT-Problem ist ein inline Script im <head>, das vor dem Rendern ausgeführt wird. Dieses Script liest LocalStorage und setzt das data-theme-Attribut synchron, bevor der Browser das Rendering startet. Da es im <head> steht und kein defer oder async hat, blockiert es kurz das Parsing – aber das ist in diesem Fall gewollt, weil es das Theme-Flimmern verhindert. In Nuxt wird dieses Script über useHead() mit script: [{ children: themeInitScript, tagPosition: 'head' }] eingefügt.

Die Cookie-basierte Alternativlösung sendet das Theme-Präferenz-Cookie bei jedem Request mit. Der Server liest den Cookie und rendert das korrekte data-theme-Attribut direkt im HTML. So sieht der Nutzer nie das falsche Theme – selbst bei deaktiviertem JavaScript. Das ist die technisch sauberste Lösung für das FOWT-Problem, erfordert aber serverseitige Cookie-Verarbeitung, was bei statischen Hosting-Umgebungen nicht möglich ist.

9. Theme-Strategien im Vergleich

Die Wahl der richtigen Dark-Mode- und Theming-Strategie hängt von den Projektanforderungen ab. Für einfache Projekte mit wenigen Komponenten reicht Tailwinds Dark-Mode-Klassen ohne Design Tokens. Für mittlere bis große Projekte sind CSS Custom Properties als Design Tokens der richtige Ansatz. Für SSR-Projekte ist Cookie-Persistenz die einzige Methode, die FOWT vollständig verhindert.

Strategie Skalierung SSR-Flash Mehrere Themes
Tailwind dark: Klassen Schlecht (hundert Stellen) Flash möglich Nicht unterstützt
CSS Custom Properties Exzellent Flash mit inline Script Beliebig viele Themes
Cookie + SSR Exzellent Kein Flash Unterstützt
Media Query only Gut Kein Flash Nur system-Präferenz
Design Tokens + Pinia Exzellent Inline Script nötig Vollständig

Die empfohlene Kombination für neue Vue-3-Projekte mit Dark Mode: CSS Custom Properties als Design Tokens, Tailwind mit darkMode: ['class', '[data-theme="dark"]'] für Utility-Klassen, ein useTheme-Composable oder Pinia-Store für die Vue-seitige State-Verwaltung, und ein inline Script im <head> für FOWT-Prevention. Bei SSR mit Nuxt: Cookie-Persistenz statt LocalStorage.

Mironsoft

Vue-3-Theming, Design-Token-Systeme und Dark-Mode-Integration

Professionelles Theming für eure Vue-3-App?

Wir implementieren ein vollständiges Design-Token-System mit Dark Mode, System-Präferenz-Erkennung, Persistenz und FOWT-Prevention – für Tailwind-CSS- und CSS-Custom-Properties-basierte Vue-3-Apps.

Token-System

CSS Custom Properties Design-Token-Hierarchie für konsistentes Theming über alle Komponenten

Dark Mode

useTheme-Composable, System-Präferenz, Persistenz und FOWT-Prevention implementieren

Multi-Theme

Erweitertes Theme-System für Branding, Barrierefreiheit und Custom-Themes aufbauen

10. Zusammenfassung

Ein professionelles Vue Dark Mode- und Theming-System basiert auf drei Schichten: CSS Custom Properties als Design Tokens für die visuelle Implementierung, ein useTheme-Composable für die Vue-seitige Reaktivität und State-Verwaltung, und Persistenz plus FOWT-Prevention für eine nahtlose Nutzererfahrung. Design Tokens machen Theme-Wechsel zu einer einzeiligen CSS-Änderung statt einer hundertfachen Komponenten-Modifikation. Das useTheme-Composable kapselt System-Präferenz-Erkennung, Toggle-Logik und DOM-Synchronisation.

Das Flash-of-Wrong-Theme-Problem ist das häufigste Qualitätsproblem bei Dark Mode-Implementierungen und löst sich mit einem inline Script im <head> oder Cookie-Persistenz für SSR. Tailwind CSS und CSS Custom Properties sind keine konkurrierenden Ansätze – sie ergänzen sich: Tokens für systemweite Designentscheidungen, Tailwind-Utilities für komponentenspezifische Anpassungen. Das resultierende System skaliert von zwei Themes auf beliebig viele, ohne die Komponentenebene anzufassen.

Vue Dark Mode und Design Tokens — Das Wichtigste auf einen Blick

Design Token Hierarchie

Primitive Tokens → semantische Tokens → Komponenten-Tokens. Dark Mode überschreibt nur semantische Ebene. Beliebige Themes ohne Komponenten-Änderungen.

useTheme-Composable

System-Präferenz via matchMedia, DOM-Sync via watchEffect, Persistenz via LocalStorage. Modulsope-State für globales Sharing.

FOWT-Prevention

Inline Script im head vor erstem Render. Bei SSR: Cookie-Persistenz für server-seitiges Theme-Rendering ohne Flash.

Tailwind-Integration

darkMode: class mit data-theme-Selektor. CSS Custom Properties als Tailwind-Color-Mapping. Tokens und Utilities ergänzen sich.

11. FAQ: Vue Dark Mode und Design Tokens

1Was sind Design Tokens für Dark Mode?
Semantisch benannte Variablen (--color-surface) statt Farbwerten. Dark Mode überschreibt Token-Werte einmal – alle Komponenten wechseln automatisch.
2Flash-of-Wrong-Theme?
Server rendert ohne Theme-Kenntnis. Browser zeigt kurz falsches Theme. Lösung: inline Script im head oder Cookie-Persistenz bei SSR.
3Tailwind class vs. media?
media: automatisch via prefers-color-scheme. class: programmatisch via DOM-Klasse. Für Vue-App mit Persistenz: class-Strategie wählen.
4System-Präferenz erkennen?
window.matchMedia('(prefers-color-scheme: dark)').matches. Für reaktive Updates: addEventListener('change') in onMounted, entfernen in onUnmounted.
5Theme persistieren?
LocalStorage für Client-only. Cookie für SSR (Server kann lesen). pinia-plugin-persistedstate automatisiert LocalStorage-Persistenz.
6Mehr als zwei Themes?
CSS Custom Properties skalieren auf beliebig viele. [data-theme='ocean'], [data-theme='forest'] – neue Themes ohne JavaScript-Änderungen.
7Primitive vs. semantische Tokens?
Primitive: konkrete Werte (#16a34a). Semantisch: Rollen (--color-primary). Dark Mode überschreibt nur semantische. Primitive sind theme-unabhängig.
8CSS Custom Properties in Tailwind?
theme.extend.colors: { surface: 'var(--color-surface)' }. Dann bg-surface als Tailwind-Klasse. Token-Änderungen wirken automatisch auf alle Utilities.
9Pinia für Theme nötig?
Nein. Composable Store reicht für Client-only. Pinia bei SSR-Isolation, DevTools-Bedarf oder Plugin-Persistenz sinnvoll.
10FOWT ohne inline Script verhindern?
Cookie-Persistenz bei SSR: Server liest Cookie und rendert data-theme direkt im HTML. Kein JavaScript-Run für korrektes Theme nötig.