prefers-color-scheme: Richtig eingesetzt in CSS
prefers-color-scheme ist das CSS-Media-Feature, das meldet, welches
Farbschema der Nutzer angefordert hat — light oder dark — so wie es im
Betriebssystem oder Browser eingestellt ist. Richtig eingesetzt heißt: nur als
Standard verwenden. Custom Properties definieren das helle Theme auf :root,
die Media Query kippt diese Standardwerte, wenn das System Dark bevorzugt, und
ein data-theme-Attribut, das ein manueller Umschalter setzt, überschreibt
beides. Die Query allein ist noch keine Implementierung, denn die meisten
echten Produkte brauchen auch den Umschalter — und ein Umschalter kann eine
Media Query nicht aus CSS heraus überschreiben.
Das hier ist die Implementierungs-Referenz für die Umschalt-Mechanik aus unserem Dark-Mode-Leitfaden: die Query, das Override-Pattern und die vier Fallstricke, die die Support-Foren füllen.
Was liest die Media Query eigentlich aus?
Die erklärte Präferenz des Nutzers, weitergereicht aus der Umgebung: Die Erscheinungsbild-Einstellung des Betriebssystems (oder ein Override auf Browser-Ebene, wo es einen gibt) taucht in CSS als Media-Feature auf, genau wie eine Viewport-Breite. Die einfachste Form:
@media (prefers-color-scheme: dark) {
/* Styles für Nutzer, die Dark angefordert haben */
}
Kein JavaScript ist im Spiel, und die Query ist live: Ändere die OS-Einstellung, und die passenden Styles greifen sofort. Die Werte des Features und Hinweise zum Support stehen auf MDNs Referenzseite. Wovon die Query nichts weiß, ist deine UI — sie meldet das System, nicht den Button in deinem Header.
Warum muss ein manueller Umschalter die Media Query überschreiben?
Weil jedes echte Produkt am Ende drei Zustände hat, nicht zwei: dem System folgen, erzwungen hell und erzwungen dunkel. Ein Nutzer, dessen OS auf Dark läuft, will deine App vielleicht trotzdem hell (lange Lesesitzungen sind ein häufiger Grund) — und umgekehrt. Die Media Query kann nur den ersten Zustand ausdrücken — deshalb legt das robuste Pattern ein Attribut obendrauf:
:root {
color-scheme: light dark;
--background: oklch(0.94 0.008 262.9);
--text-primary: oklch(0.28 0.02 262.9);
}
/* Standard: dem System folgen */
@media (prefers-color-scheme: dark) {
:root {
--background: oklch(0.22 0.015 262.9);
--text-primary: oklch(0.94 0.008 262.9);
}
}
/* Erzwungene Zustände: das Attribut gewinnt über den Standard */
[data-theme="light"] {
color-scheme: light;
--background: oklch(0.94 0.008 262.9);
--text-primary: oklch(0.28 0.02 262.9);
}
[data-theme="dark"] {
color-scheme: dark;
--background: oklch(0.22 0.015 262.9);
--text-primary: oklch(0.94 0.008 262.9);
}
body { background: var(--background); color: var(--text-primary); }
Lies es als eine Kaskade von Standardwerten: :root sagt hell, die Media Query
sagt „außer das System bevorzugt Dark”, das Attribut sagt „außer der Nutzer hat
uns etwas anderes gesagt”. Ohne Attribut folgt die Seite dem System; mit einem
gewinnt die In-App-Wahl des Nutzers. Der JavaScript-Teil ist eine Zeile plus
Speicherung:
const saved = localStorage.getItem("theme"); // "light" | "dark" | null
if (saved) document.documentElement.dataset.theme = saved;
null — der dritte Zustand — bedeutet „kein Attribut setzen und die Media Query
entscheiden lassen”. Explizit "system" zu speichern und das Attribut zu
entfernen, funktioniert genauso.
Das ist das komplette durchgearbeitete Beispiel: rund fünfundzwanzig Zeilen CSS und drei Zeilen JavaScript decken System-Folgen, beide erzwungenen Zustände und native Formularelemente ab. Die Werte selbst kommen paarweise — einer pro Theme, pro Rolle — und genau hier reicht die Token-Ebene dem Pattern ihre Daten.
Sieh dir in Scale Composer ein Rollen-Set an, das die Werte beider Themes hält — jede semantische Rolle mit ihrem hellen und dunklen Wert nebeneinander, bereit zum Einfügen in die beiden Blöcke oben.

Welche Fallstricke füllen die Support-Foren?
Formularelemente ignorieren dein Theme. Dein CSS färbt die Seite dunkel,
aber Scrollbars, Checkboxen und Dropdowns bleiben hell — weil der Browser die
zeichnet, und dem hat es niemand gesagt. Der Fix ist der Einzeiler, der schon im
Pattern oben steht: color-scheme: light dark sagt dem Browser, welche Schemata
die Seite unterstützt, und die Blöcke für die erzwungenen Zustände engen das
ein, sodass native Elemente dem Override folgen, nicht nur der Query.
Das Aufblitzen des falschen Themes. Ein Nutzer mit Dark-Präferenz lädt die
Seite, sieht ein weißes Aufblitzen, dann greift das Theme. Das passiert, wenn
der gespeicherte Override von einem Skript gesetzt wird, das erst nach dem
ersten Paint läuft. Der ehrliche Fix ist ein Inline-Skript im <head> — vor dem
stylesheet-abhängigen Rendering, nicht deferred, nicht gebündelt — das
localStorage liest und das Attribut setzt. Es blockiert das Rendering
absichtlich; halte es auf diese wenigen Zeilen begrenzt.
Bilder und Embeds folgen nicht. Ein <picture>-Element kann
Quellen pro Theme tauschen mit
media="(prefers-color-scheme: dark)", aber das folgt dem System, nicht
deinem data-theme-Override — eine bekannte Asymmetrie des Attribut-Patterns.
Iframes lösen die Query in ihrem eigenen Kontext auf. Für Inhalte, die dem
In-App-Umschalter folgen müssen, muss CSS (oder ein kleines Skript) das Tauschen
übernehmen.
Der Umschalter animiert alles. Ein pauschales transition: all auf
theme-betroffenen Elementen macht aus dem Umschalten eine langsame Überblendung
jeder Farbe auf dem Bildschirm. Beschränke Transitions auf die Eigenschaften,
die sie brauchen, oder deaktiviere Transitions kurz während des Umschaltens.
Macht die Query das Dark Theme gut?
Nein — sie beantwortet nur, welches Theme gezeigt wird. Ob das Dark Theme das Umschalten wert ist, entscheidet die Palette hinter den Custom Properties: Ein dunkles Set, abgeleitet mit einer eigenen Helligkeitskurve und einem Chroma-Boost, liest sich wie ein gestaltetes Theme, während invertierte helle Werte sich wie das Negativ eines solchen lesen. Die Query ist das Routing; die Werte sind das Produkt. Diese Werte abzuleiten ist das Thema des Rests dieses Hubs.
Prüfe das Pattern gegen deine eigenen Werte
Die beiden Blöcke im Pattern sind nur so gut wie die Paare, mit denen du sie füllst. Lade eine Palette und prüfe den Anker jeder Rolle unter beiden Themes — dieselben Rollennamen, die pro Theme aufgelöst werden, Kontrast-Untergrenzen auf jeder Seite neu geprüft, sodass die Werte, die du in die Media Query und den Attribut-Block einfügst, bereits verifizierte Paare sind.