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.
Inhaltsverzeichnis
- 1. Dark Mode: drei Layers – System, Nutzerpräferenz, Klasse
- 2. $persist: Nutzerpräferenz dauerhaft speichern
- 3. prefers-color-scheme als System-Default
- 4. Flash of Wrong Theme (FOWT) verhindern
- 5. Tailwind CSS dark: Klassen orchestrieren
- 6. Toggle-UI: Icons, Transitions und Zustandsanzeige
- 7. Drei-Zustand-Toggle: hell, dunkel, System
- 8. Systemwechsel zur Laufzeit erkennen
- 9. Dark-Mode-Patterns im Vergleich
- 10. Zusammenfassung
- 11. FAQ
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).