5 Min. Lesezeit

Dark Mode richtig bauen – Tokens, FOUC-Guard & prefers-color-scheme

Light/Dark Mode richtig bauen: Mit CSS Custom Properties, prefers-color-scheme und einem FOUC-Guard, der das Flackern beim Laden verhindert. Aus der Praxis.

css

Einen Dark Mode “mal eben” einzubauen klingt nach einer Nachmittagsaufgabe. Ein paar Farben tauschen, einen Button dranhängen, fertig. Bis man merkt: Die Seite flackert beim Laden kurz weiß auf, der Toggle vergisst die Wahl beim Reload, und irgendwo bleibt eine hartkodierte Farbe stehen, die im dunklen Modus aussieht wie ein Schlagloch.

Es gibt einen Weg, das sauber zu machen – ohne Framework, ohne theme-provider, mit reinem CSS und einer Handvoll Zeilen JavaScript. So sieht er aus.

Das Fundament: Farben als Variablen, nicht als Werte

Der häufigste Fehler ist, Farben direkt im CSS zu schreiben:

.card {
  background: #1a1b2e;  /* ← klebt fest am dunklen Modus */
  color: #edf2f4;
}

Sobald du das an zwanzig Stellen gemacht hast, ist ein zweites Theme die Hölle. Die Lösung: Semantische Custom Properties. Du definierst die Farben einmal an einer zentralen Stelle und referenzierst überall nur noch den Namen.

:root,
[data-theme="dark"] {
  --black:    #1a1b2e;
  --white:    #edf2f4;
  --gray-500: #8d99ae;
  --accent:   #ef233c;
}

.card {
  background: var(--black);
  color: var(--white);
}

Die Karte weiß jetzt nicht mehr, welche Farbe sie hat – nur noch, dass sie “Hintergrund” und “Vordergrund” will. Das ist der entscheidende Schritt.


Das zweite Theme: dieselben Namen, andere Werte

Jetzt kommt der Trick. Das helle Theme überschreibt dieselben Variablen unter einem anderen Selektor:

[data-theme="light"] {
  --black:    #edf2f4;   /* was im Dark "schwarz" war, ist hier "weiß" */
  --white:    #1a1b2e;   /* und umgekehrt */
  --gray-500: #4e5168;
  --accent:   #d80032;
}

Sieht erstmal verwirrend aus, dass --black im hellen Modus ein heller Wert ist. Aber genau das ist der Punkt: Die Variablennamen beschreiben ihre Rolle, nicht ihre Erscheinung. --black heißt eigentlich “die dominante Flächenfarbe”, --white ist “der Text darauf”. In beiden Themes funktioniert die Karte mit identischem CSS – nur die Werte kippen.

Profi-Tipp: Wer es ganz sauber will, benennt die Variablen nach Funktion statt nach Farbe: --surface, --text, --text-muted, --accent. Dann liest sich der Code selbsterklärend und niemand stolpert über ein helles --black.

Der Wechsel passiert ausschließlich über ein Attribut am <html>:

<html data-theme="dark">   <!-- oder "light" -->

Mehr braucht es im CSS nicht. Kein Umschreiben von Komponenten, keine doppelten Stylesheets.


Semantische Status-Farben nicht vergessen

Erfolg-Grün und Fehler-Rot brauchen in beiden Themes unterschiedliche Sättigung. Ein leuchtendes Grün, das auf dunklem Grund knallt, wirkt auf Weiß grell und billig. Also: auch die Status-Farben pro Theme definieren.

[data-theme="dark"] {
  --success: #27c93f;  /* leuchtend auf dunklem Grund */
  --error:   #e05a5a;
  --warning: #d4a24a;
}

[data-theme="light"] {
  --success: #1a8a31;  /* abgedunkelt für Kontrast auf Weiß */
  --error:   #c0392b;
  --warning: #b87d1a;
}

Das ist der Unterschied zwischen “hat einen Dark Mode” und “wurde im Dark Mode wirklich angeschaut”.


Die Standardvorgabe: dem System folgen

Bevor der Nutzer überhaupt etwas anklickt, sollte die Seite raten – und zwar richtig. Wer sein OS auf Dark gestellt hat, will keine weiße Bombe ins Gesicht. Das verrät uns prefers-color-scheme:

const preference = window.matchMedia('(prefers-color-scheme: light)').matches
  ? 'light'
  : 'dark';

Das ist die Vorgabe. Sobald der Nutzer aber selbst toggelt, hat seine Wahl Vorrang – und die merken wir uns:

const saved = localStorage.getItem('theme');
const theme = saved || preference;  // Nutzerwahl schlägt Systemvorgabe

Der wichtigste Teil: der FOUC-Guard

Jetzt zum Problem, an dem die meisten Dark-Mode-Implementierungen scheitern: das Flackern.

Wenn du den Theme-Wechsel in deinem normalen JavaScript-Bundle machst, läuft das erst, nachdem der Browser die Seite schon gerendert hat. Ergebnis: Die Seite erscheint kurz im Standard-Theme (meist hell), dann springt sie um. Dieses Aufblitzen heißt FOUC – Flash of Unstyled Content. Es sieht billig aus und es ist vermeidbar.

Der Trick: ein winziges, synchrones Inline-Script ganz oben im <head>, vor jedem CSS und jedem sichtbaren Element. Es setzt das data-theme-Attribut, bevor der erste Pixel gezeichnet wird.

<head>
  <!-- ... meta tags ... -->

  <!-- FOUC-Guard: läuft VOR dem ersten Paint -->
  <script>
  (function () {
    var saved = localStorage.getItem('theme');
    var theme = saved
      || (window.matchMedia &&
          window.matchMedia('(prefers-color-scheme: light)').matches
            ? 'light' : 'dark');
    document.documentElement.setAttribute('data-theme', theme);
  })();
  </script>

  <!-- erst danach: dein normales CSS -->
  <link rel="stylesheet" href="/main.css">
</head>

Drei Dinge machen das wasserdicht:

  1. Inline und synchron – kein defer, kein externes File. Der Browser stoppt hier, führt das Script aus, und erst dann geht es weiter. Genau das wollen wir.
  2. Ganz oben – vor dem CSS, vor dem <body>. Das Attribut steht, bevor irgendetwas Sichtbares existiert.
  3. Das IIFE (function(){ ... })() – hält die Variablen aus dem globalen Scope raus.

Ja, ein Inline-Script blockiert kurz das Parsing. Aber es sind drei Zeilen, die in Mikrosekunden laufen – der Preis fürs flackerfreie Laden ist vernachlässigbar.


Der Toggle: umschalten und merken

Jetzt erst kommt der sichtbare Button – und der ist fast schon banal, weil die ganze Arbeit oben passiert ist:

const toggle = document.querySelector('.theme-toggle');

toggle.addEventListener('click', () => {
  const current = document.documentElement.getAttribute('data-theme');
  const next = current === 'dark' ? 'light' : 'dark';

  document.documentElement.setAttribute('data-theme', next);
  localStorage.setItem('theme', next);  // merken für den nächsten Besuch
});

Ein Detail noch für die Kür: Wenn der Nutzer sein System umstellt, während die Seite offen ist – aber nie selbst getoggelt hat –, sollte die Seite mitziehen. Dafür lauscht man auf die Media Query:

window.matchMedia('(prefers-color-scheme: light)')
  .addEventListener('change', (e) => {
    // nur reagieren, wenn der Nutzer keine eigene Wahl getroffen hat
    if (localStorage.getItem('theme')) return;
    document.documentElement.setAttribute('data-theme', e.matches ? 'light' : 'dark');
  });

Bonus: die Browser-UI mitfärben

Eine Kleinigkeit, die selten gemacht wird, aber den Unterschied ausmacht – die Adressleiste auf Mobilgeräten in der Theme-Farbe:

<meta name="theme-color" content="#1a1b2e" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#edf2f4" media="(prefers-color-scheme: light)">

So wirkt die Seite auch außerhalb des eigentlichen Inhalts wie aus einem Guss.


Kurze Übersicht

1. Farben als semantische CSS-Variablen definieren  (--surface, --text, ...)
2. Zweites Theme: gleiche Namen, andere Werte        ([data-theme="light"])
3. Status-Farben pro Theme anpassen                  (Sättigung!)
4. Standard via prefers-color-scheme erraten
5. Nutzerwahl in localStorage merken
6. FOUC-Guard: Inline-Script ganz oben im <head>     ← der entscheidende Teil
7. Toggle setzt Attribut + speichert
8. theme-color Meta-Tags für die Browser-UI

Ein guter Dark Mode ist keine Funktion, die man “dazuschaltet” – er ist eine Entscheidung über die Architektur der Farben. Wer von Anfang an mit semantischen Variablen arbeitet, bekommt das zweite Theme fast geschenkt. Und der FOUC-Guard ist der kleine, unscheinbare Code, der den Unterschied zwischen “sieht selbstgebaut aus” und “fühlt sich nativ an” macht. Drei Zeilen, ganz oben. Mehr nicht.