x-data
Alpine
Alpine.js · Dark Mode · $persist · System-Preference · Tailwind CSS
Dark Mode Toggle mit $persist
und System-Preference

Ein Dark Mode der beim nächsten Seitenaufruf vergessen ist, oder der beim Laden kurz hell aufblitzt bevor er dunkel wird – das sind die häufigsten Fehler. Alpine.js löst beides: $persist speichert die Nutzerpräferenz dauerhaft in localStorage, und ein Blocking-Script vor Alpine.js verhindert den Flash of Wrong Theme.

14 Min. Lesezeit $persist · prefers-color-scheme · FOWT · Tailwind dark: Alpine.js 3.x · Tailwind CSS v4 · LocalStorage

1. Dark Mode: drei Layers – System, Nutzerpräferenz, Klasse

Ein vollständiger Dark-Mode-Toggle in Alpine.js besteht aus drei übereinanderliegenden Layers. Der unterste Layer ist die Systempräferenz: prefers-color-scheme: dark gibt an, ob das Betriebssystem des Nutzers Dark Mode aktiviert hat. Darüber liegt die gespeicherte Nutzerpräferenz: Hat der Nutzer auf der Website explizit einen Modus gewählt, gilt dieser unabhängig von der Systemeinstellung. Der oberste Layer ist die CSS-Klasse auf dem <html>-Element: class="dark" aktiviert alle dark:-Varianten in Tailwind CSS.

Das Schwierige an diesem System ist die zeitliche Reihenfolge: Die CSS-Klasse muss gesetzt sein, bevor der Browser das erste Pixel rendert. Wenn Alpine.js erst nach dem Laden der Seite initialisiert wird und dann die Klasse setzt, sieht der Nutzer kurz die falsche Variante. Das ist der berüchtigte Flash of Wrong Theme (FOWT) – der sichtbare Wechsel von hell zu dunkel beim Seitenaufruf. Die Lösung ist ein kleines Blocking-Script im <head> der Seite, das die localStorage-Werte ausliest und die Klasse synchron setzt, bevor CSS und Alpine.js geladen werden.

In Hyvä-Projekten ist das besonders relevant: Hyvä generiert Seiten server-seitig, und der Browser rendert sie sofort beim Empfang der ersten Bytes. Ein Alpine.js-basierter Dark Mode ohne Blocking-Script produziert in Hyvä immer einen FOWT, weil Alpine.js nach dem initialen Rendering der Seite initialisiert wird. Der Blocking-Script-Ansatz ist daher keine optionale Optimierung, sondern eine Pflichtkomponente einer guten Dark-Mode-Implementierung.

2. $persist: Nutzerpräferenz dauerhaft speichern

Alpine.js $persist ist ein Magic Property aus dem @alpinejs/persist-Plugin, das Variablen automatisch in localStorage synchronisiert. Wenn eine Variable mit $persist deklariert ist, wird jede Änderung sofort in localStorage geschrieben und beim nächsten Seitenaufruf wieder eingelesen. Für Dark Mode bedeutet das: this.dark = this.$persist(null) – wobei null bedeutet, dass keine explizite Nutzerpräferenz gesetzt ist (System-Default).

Der localStorage-Schlüssel kann mit .$as('keyName') angepasst werden: this.dark = this.$persist(null).$as('colorScheme'). Das ist wichtig für Projekte mit mehreren Hyvä-Themes oder mehreren Alpine.js-Instanzen, die sich denselben localStorage teilen. Ohne expliziten Schlüssel verwendet $persist den Property-Namen als Schlüssel, was bei generischen Namen wie dark zu Kollisionen führen kann.

// Blocking-Script im  (vor Alpine.js laden!)
// Verhindert Flash of Wrong Theme — läuft synchron beim Parsen
(function () {
  const stored = localStorage.getItem('colorScheme');
  const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
  const isDark = stored === 'dark' || (stored === null && systemDark);
  if (isDark) {
    document.documentElement.classList.add('dark');
  }
})();

// Alpine.js Dark-Mode-Komponente mit $persist
// Datei: Mironsoft_Theme/templates/page/js/dark-mode.phtml
function darkModeToggle() {
  return {
    // $persist synchronisiert mit localStorage — Key: 'colorScheme'
    // null = System-Default, 'light' = explizit hell, 'dark' = explizit dunkel
    preference: null,

    init() {
      // $persist einrichten (wird in Alpine.js-Kontext via x-init initialisiert)
      this.preference = this.$persist(null).$as('colorScheme');

      // System-Präferenz-Wechsel zur Laufzeit beobachten
      const mql = window.matchMedia('(prefers-color-scheme: dark)');
      mql.addEventListener('change', (e) => {
        if (this.preference === null) {
          this.applyTheme(e.matches ? 'dark' : 'light');
        }
      });

      // Initial anwenden
      this.applyCurrentTheme();

      // Wenn preference sich ändert, Theme anwenden
      this.$watch('preference', () => this.applyCurrentTheme());
    },

    get isDark() {
      if (this.preference === 'dark') return true;
      if (this.preference === 'light') return false;
      // null = System-Default
      return window.matchMedia('(prefers-color-scheme: dark)').matches;
    },

    applyCurrentTheme() {
      this.applyTheme(this.isDark ? 'dark' : 'light');
    },

    applyTheme(theme) {
      document.documentElement.classList.toggle('dark', theme === 'dark');
    },

    toggle() {
      this.preference = this.isDark ? 'light' : 'dark';
    },

    setSystem() {
      this.preference = null; // null = System-Default verwenden
    }
  };
}

3. prefers-color-scheme als System-Default

Das prefers-color-scheme Media Feature ist das CSS- und JavaScript-Interface zur Betriebssystem-Einstellung. In JavaScript liest man es mit window.matchMedia('(prefers-color-scheme: dark)').matches. Dieser Wert ist zum Zeitpunkt des Aufrufs synchron verfügbar – kein Promise, kein Event-Wait. Das macht ihn ideal für das Blocking-Script im <head>.

Für die Laufzeit-Beobachtung nutzt man addEventListener('change', ...) auf dem MediaQueryList-Objekt. Wenn der Nutzer während der Browser-Session die Systemeinstellung ändert, feuert dieses Event. In der Alpine.js-Komponente reagiert man darauf, aber nur wenn der Nutzer keine explizite Präferenz gesetzt hat (preference === null). Hat der Nutzer manuell einen Modus gewählt, soll die System-Änderung diesen nicht überschreiben.

4. Flash of Wrong Theme (FOWT) verhindern

Der Flash of Wrong Theme entsteht weil der Browser das HTML parst, CSS anwendet, die Seite initial in der Light-Mode-Version rendert, und erst danach JavaScript ausführt. Wenn Alpine.js dann die dark-Klasse setzt, führt der Browser einen Re-Paint durch – der Nutzer sieht kurz die helle Version, bevor die dunkle erscheint. Bei schnellen Verbindungen dauert das Millisekunden, auf langsameren Geräten oder mit viel CSS sind es mehrere hundert Millisekunden sichtbares Flackern.

Die Lösung ist ein synchrones Blocking-Script direkt im <head> der Seite, vor dem ersten Stylesheet. Dieses Script liest localStorage und setzt die Klasse synchron – während der Browser noch HTML parst, bevor er überhaupt mit dem Rendern beginnt. Das Script muss minimal sein (kein Modul, kein defer, kein async), damit der Browser beim Parsen nicht blockiert. In Hyvä wird das Script per Layout-XML in den <head> eingefügt, mit CSP-Registrierung.

5. Tailwind CSS dark: Klassen orchestrieren

Tailwind CSS v4 unterstützt Dark Mode über die dark:-Variante, die aktiviert wird wenn das <html>-Element die Klasse dark hat (Selector-Strategie). In der Tailwind-Konfiguration ist das mit darkMode: 'class' aktiviert. Dann funktionieren Klassen wie dark:bg-slate-900 dark:text-white automatisch, sobald <html class="dark"> gesetzt ist.

In Hyvä-Projekten mit Tailwind CSS v4 (CSS-first Approach) wird die Dark-Mode-Konfiguration in der CSS-Datei gesetzt. Das erlaubt auch den Einsatz von CSS Custom Properties für Dark-Mode-Farben – eine sauberere Architektur als die direkte Utility-Klassen-Variante. Die Alpine.js-Komponente orchestriert nur die dark-Klasse auf <html>; das CSS kümmert sich um alle visuellen Konsequenzen.

// Drei-Zustand-Toggle: System | Hell | Dunkel
// Erweiterte Version mit explizitem System-Modus
function threeStateTheme() {
  return {
    // 'system' | 'light' | 'dark'
    mode: 'system',

    init() {
      this.mode = this.$persist('system').$as('themeMode');
      this.applyTheme();
      this.$watch('mode', () => this.applyTheme());

      // System-Wechsel beobachten
      window.matchMedia('(prefers-color-scheme: dark)')
        .addEventListener('change', () => {
          if (this.mode === 'system') this.applyTheme();
        });
    },

    applyTheme() {
      let dark = false;
      if (this.mode === 'dark') dark = true;
      else if (this.mode === 'system') {
        dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
      }
      document.documentElement.classList.toggle('dark', dark);
    },

    // Getter für UI-Darstellung
    get label() {
      const labels = { system: 'System', light: 'Hell', dark: 'Dunkel' };
      return labels[this.mode];
    },

    get icon() {
      if (this.mode === 'dark') return 'moon';
      if (this.mode === 'light') return 'sun';
      return 'monitor'; // system
    },

    // Cycling durch die drei Zustände
    cycle() {
      const order = ['system', 'light', 'dark'];
      const next = (order.indexOf(this.mode) + 1) % order.length;
      this.mode = order[next];
    },

    setMode(newMode) {
      this.mode = newMode;
    }
  };
}

6. Toggle-UI: Icons, Transitions und Zustandsanzeige

Die Dark-Mode-Toggle-UI besteht aus einem Button, der den aktuellen Zustand anzeigt und beim Klicken zwischen den Modes wechselt. Das häufigste Muster: Ein Sonne-Icon für Light Mode, ein Mond-Icon für Dark Mode, die mit x-show und x-transition gewechselt werden. Bei einem Drei-Zustand-Toggle kommt ein Monitor-Icon für den System-Modus hinzu.

Accessibility ist beim Toggle-Button besonders wichtig: Das Icon allein reicht nicht als zugänglicher Name. :aria-label="'Theme: ' + label" gibt Screen Readern einen sprechenden Button-Namen, der den aktuellen Modus beschreibt. aria-pressed ist für diesen Anwendungsfall nicht ideal, weil es einen binären Toggle suggeriert. Stattdessen beschreibt aria-label den Zustand vollständig: "Theme: Dunkel" ist präziser als "aktiviert/deaktiviert".

7. Drei-Zustand-Toggle: hell, dunkel, System

Der einfache binäre Dark-Mode-Toggle überschreibt immer die System-Präferenz. Ein durchdachtes UX-Pattern bietet drei Zustände: explizit hell, explizit dunkel, und System-Follow. Im System-Follow-Modus folgt die App der Betriebssystem-Einstellung – wenn der Nutzer am Abend automatisch in den Dark Mode wechselt, folgt die App. Dieser Modus ist für viele Nutzer der bevorzugte, weil er die System-Präferenz respektiert ohne manuelle Einstellung zu erfordern.

In Alpine.js ist der Drei-Zustand-Toggle eine Erweiterung des normalen Patterns: Die preference-Variable hat drei mögliche Werte ('light', 'dark', null oder 'system'), und die Logik zur Theme-Anwendung prüft erst die explizite Präferenz, dann die System-Einstellung. Mit $persist wird der gewählte Modus gespeichert, und beim nächsten Seitenaufruf wird derselbe Drei-Zustand wiederhergestellt.

8. Systemwechsel zur Laufzeit erkennen

Moderne Betriebssysteme können das Farbschema automatisch oder manuell wechseln – Windows 10/11, macOS und iOS unterstützen das alle. Wenn ein Nutzer sein System von Hell auf Dunkel umstellt, während die Browser-Session läuft, soll eine korrekte Alpine.js Dark Mode Implementierung darauf reagieren. Das geschieht über den change-Event des MediaQueryList-Objekts.

Die Reaktion auf den System-Wechsel darf aber nur stattfinden, wenn der Nutzer keine explizite Präferenz gesetzt hat. Wenn ein Nutzer den Dark Mode manuell deaktiviert hat, soll das System-Theme den Dark Mode nicht wieder einschalten. Nur wenn preference === null (System-Follow-Modus), soll der Systemwechsel das Theme umschalten. Diese Logik ist in der Alpine.js-Komponente leicht zu implementieren: if (this.preference === null) { this.applyTheme(); }.

Problem Anti-Pattern Lösung Erklärung
FOWT Alpine.js setzt dark-Klasse Blocking-Script im <head> Vor erstem Render ausgeführt
Persistenz sessionStorage oder Cookie $persist mit localStorage Dauerhaft, Tab-übergreifend
System-Default Immer Light Mode als Default prefers-color-scheme lesen Respektiert OS-Einstellung
Tailwind Dark media-Strategie class-Strategie (darkMode: 'class') JavaScript-kontrollierbar
Laufzeit-Wechsel Kein Listener auf MQL mql.addEventListener('change') Reagiert auf OS-Wechsel

Mironsoft

Alpine.js, Tailwind CSS und Hyvä-Theme-Entwicklung

Dark Mode für dein Hyvä-Projekt?

Wir implementieren Dark Mode ohne FOWT, mit persistenter Nutzerpräferenz, System-Follow-Modus und vollständiger Tailwind-Integration – für Hyvä-Themes und Magento 2.

Dark-Mode-Implementierung

FOWT-freie Implementierung mit $persist, Blocking-Script und Tailwind dark:

Design-System

CSS Custom Properties und Tailwind-Konfiguration für konsistentes Dual-Theme

Hyvä-Integration

CSP-konforme Layout-XML-Integration für alle Hyvä-Theme-Seiten

10. Zusammenfassung

Ein vollständiger Dark Mode mit Alpine.js besteht aus vier Komponenten: dem Blocking-Script im <head> gegen FOWT, $persist für dauerhafte Nutzerpräferenz, dem prefers-color-scheme-Listener für System-Follow-Modus, und der dark-Klasse auf <html> als Tailwind-Trigger. Keine dieser Komponenten ist optional – das Weglassen einer führt zu sichtbaren Problemen.

Der Drei-Zustand-Toggle (hell, dunkel, System) ist die beste UX für Dark Mode, weil er dem Nutzer sowohl manuelle Kontrolle als auch die Option gibt, der System-Einstellung zu folgen. Mit Alpine.js $persist ist die Persistenz trivial umgesetzt. Das Blocking-Script ist der technisch anspruchsvollste Teil – aber ohne es entsteht immer ein FOWT, der bei Nutzern den Eindruck einer ruckelnden, schlechten Website hinterlässt.

Dark Mode mit Alpine.js — Das Wichtigste auf einen Blick

Blocking-Script gegen FOWT

Synchrones Script im <head> vor CSS. Liest localStorage, setzt dark-Klasse auf <html>. Vor erstem Pixel-Render ausgeführt.

$persist für Persistenz

$persist(null).$as('colorScheme') — null für System-Default. Synchronisiert automatisch mit localStorage. Kein manuelles Storage-Management.

System-Follow-Modus

prefers-color-scheme lesen. MQL-addEventListener('change') für Laufzeit-Wechsel. Nur reagieren wenn preference === null.

Tailwind-Integration

darkMode: 'class' in Tailwind-Konfiguration. dark: Varianten auf allen Elementen. document.documentElement.classList.toggle('dark', isDark).

11. FAQ: Dark Mode mit Alpine.js und $persist

1Was ist ein Flash of Wrong Theme?
Kurzes Aufblitzen der falschen Theme-Variante beim Laden. Entsteht wenn JavaScript dark-Klasse erst nach initialem Rendering setzt. Blocking-Script im head verhindert es.
2Was macht $persist?
Synchronisiert Alpine.js-Variablen automatisch mit localStorage. Jede Änderung sofort gespeichert, beim nächsten Aufruf wiederhergestellt.
3darkMode: 'class' vs. 'media' in Tailwind?
media nur für System-Präferenz. class erlaubt JavaScript-kontrollierten Toggle. Für manuelle Umschaltung immer class-Strategie verwenden.
4FOWT in Hyvä verhindern?
Synchrones Blocking-Script im head, vor erstem Stylesheet. localStorage lesen, classList.add('dark') synchron setzen während HTML-Parsing.
5System-Follow-Modus implementieren?
preference === null bedeutet System-Follow. prefers-color-scheme lesen. MQL-change-Listener für Laufzeit-Wechsel. Manuelle Auswahl überschreibt System.
6$persist mit SSR/Cookies kombinieren?
Für SSR-Scenarios Cookies statt localStorage. $persist und Blocking-Script für Client-Rendering wie Hyvä optimiert. Cookie-Wert beim ersten Request serverseitig auslesbar.
7localStorage nicht verfügbar?
$persist fällt auf In-Memory zurück. Im Blocking-Script try-catch um localStorage nutzen. Komponente funktioniert für aktuelle Session auch ohne Persistenz.
8Dark Mode korrekt testen?
OS-Einstellungen oder DevTools Rendering-Tab (Emulate prefers-color-scheme). localStorage direkt manipulieren für FOWT-Test. Hard Reload für Blocking-Script testen.
9Tailwind dark: Klassen — Beispiele?
dark:bg-slate-900, dark:text-white, dark:border-slate-700. Mit darkMode: 'class': dark-Klasse auf html aktiviert alle dark:-Varianten automatisch.
10Blocking-Script und Hyvä CSP?
Ja, $hyvaCsp->registerInlineScript() auch für das Blocking-Script im head. Gilt für jeden Inline-Script-Block in Hyvä-Templates ohne Ausnahme.