Vergleich von neun Techniken für den Dunkelmodus, von Filter-Tricks bis hin zu Server-Cookies
Vergleichen Sie neun Möglichkeiten, einen Dunkelmodus in eine Webanwendung einzufügen – von Invertierungsfiltern über Tokens, light-dark() und Server-Cookies – und erfahren Sie, welche Fehler jede Methode heimlich verursacht.
Der Dunkelmodus wird oft als Wahl zwischen einer schnellen Lösung und der richtigen Lösung dargestellt, doch Produktionsseiten nutzen mindestens neun verschiedene Techniken, wobei jede von ihnen nur einen Teil des Problems behebt. Die vom jeweiligen Verfahren ignorierten Aspekte treten später in Form von flackernden Seiten, kaputten festgelegten Überschriften oder Farben auf, die sich einfach nicht ändern lassen. Diese Anleitung bewertet diese neun Ansätze, erläutert, was bei jedem richtig und falsch läuft, und behandelt anschließend die Details, die selbst sorgfältige Implementierungen ins Straucheln bringen: das Auftauchen des falschen Themes, die color-scheme-Eigenschaft, Dreistatus-Einstellungen, Übergänge, eingebettete Inhalte sowie die Gestaltung der Farbpalette.
Die unten genannten Verhaltensbeschreibungen basieren auf primären Quellen: Entwürfen der CSS Working Group, dem WHATWG HTML Standard, den maschinenlesbaren browser-compat-data-Daten hinter MDN, Baseline-Statusdaten sowie dem Quellcode der next-themes-Bibliothek, die mit headless Chromium überprüft wurden. Zwei weit verbreitete Annahmen halten dieser Prüfung nicht stand, und ein bestimmtes Verhalten – nämlich filter, der Nachkommen mit position: fixed erfasst – stellt sich als viel besserer Grund heraus, den Invert-Trick zu vermeiden, als das oft angeführte, vage formulierte Leistungsargument.
Dunkelmodus bedeutet drei separate Probleme
„Ein dunkles Theme hinzufügen“ klingt nach einer einzigen Aufgabe. In der Praxis umfasst es drei eigenständige Fragen, sodass man eine davon perfekt beantworten kann und dennoch etwas Defektes herausbringt:
- Welches Theme soll angezeigt werden? Das Betriebssystem hat eine Vorliebe, der Benutzer möchte diese möglicherweise überschreiben, und er benötigt außerdem eine Möglichkeit, wieder der Systemeinstellung zu folgen. Ein einfacher Ein-/Ausschalter beseitigt diese dritte Option endgültig.
- Wie ändern sich die Farben? Ein einziger Schalter muss jede Fläche, jeden Rand, jedes Icon und jeden Schatten aktualisieren, wobei seine Position im Grunde die CSS-Architektur bestimmt.
- Wann wird das Theme angewendet? Wenn die Entscheidung nach dem ersten Zeichnen getroffen wird, sehen die Benutzer, wie die Seite vor ihren Augen ihre Farbe ändert.
Jede der unten aufgeführten Techniken beantwortet einen Teil dieser Fragen. Das Muster ist offensichtlich: Die sogenannten „lazy“-Ansätze kümmern sich in der Regel nur um die zweite Frage und ignorieren die erste sowie die dritte völlig.
Wie man die Rangliste liest
Die drei besten Optionen sind keine Konkurrenten. Sie bilden eine einzige Lösung: Semantische Tokens bilden die Grundlage, light-dark() ist eine kompaktere Art, diese Tokens darzustellen, und ein vom Server gelesenes Cookie dient dazu, das ausgewählte Theme ohne Flash zu liefern. Die Plätze vier bis sechs stellen echte Kompromisse dar, bei denen man sich für eine Option entscheiden muss. Die Plätze sieben bis neun sind technischer Schuldenberg.
Platz 9: Umkehren der gesamten Seite mit filter
Der kürzeste mögliche Dunkelmodus wendet eine Umkehrung sowie eine Farbrotation auf das Wurzelelement an:
html {
filter: invert(1) hue-rotate(180deg);
}
Dadurch wird sofort eine zweite Regel eingeführt, die jedes Medienelement erneut umkehrt, sodass Fotos und Videos wieder normal aussehen:
/* now patch back everything it broke */
img, video, canvas, svg, [style*="url("] {
filter: invert(1) hue-rotate(180deg);
}
Der häufigste Einwand betrifft die Leistung, was schwer klar nachzuweisen ist. Es gibt jedoch einen viel stärkeren Einwand. MDNs Dokumentation zu Contain-Blöcken macht das deutlich: Ein filter, der auf etwas anderes als none gesetzt ist, verwandelt das Element in den Contain-Block für Nachkommen mit position: fixed und position: absolute. Zudem wird ein neuer Stapelkontext eingeleitet, was heimlich die Berechnung des z-index aller darunterliegenden Elemente verändert.
Ein headless-Test macht das konkret. Platziere eine feste Leiste innerhalb eines gefilterten Wrappers auf einer 3000px hohen Seite in Chromium 141, scrolle 400px nach unten und misse, wo sich die Leiste befindet:
await p.evaluate(() => window.scrollTo(0, 400));
// -> { "fixed_viewportTop": 100, "abs_viewportTop": 100 }
// A truly viewport-fixed element reports top: 0 after any scroll.
Der Balken gibt einen Ansichtsbereichs-Offset von 100 anstelle von 0 an, wodurch er nicht mehr festgehalten wird; er scrollt zusammen mit dem Inhalt. Wenn dieser Filter auf html angewendet wird, zerstört er somit alle festen Überschriften, festsitzenden Navigationselemente, Modallayer, Toast-Anzeigen sowie Schiebetüren auf der Seite. Das ist ein Korrekturfehler, den man in wenigen Zeilen nachstellen kann – kein Frage des Geschmacks.
Die verbleibenden Probleme sind bekannt:
- Jedes Rasterbild benötigt eine Gegenumkehrung, und Logos mit eingebauten Markenfarben erscheinen weiterhin falsch, da eine Drehung der Farbtönung um 180 Grad in keinem Farbraum eine genaue Umkehr darstellt.
- Markenfarben werden zu ihren mathematischen Gegenteilen statt zu einer entworfenen dunklen Palette.
filter: grayscale(50%) für Fotos vorgeschlagen und invert(100%) ausschließlich für einfarbige Icons.Ränge 8 und 7: Ansätze, die funktionieren – bis sie es nicht mehr tun
Überwrite pro Komponente
Die natürliche erste Herangehensweise ist es, eine dunkle Variante für jede Komponente zu schreiben. Das ist nicht unbedingt falsch, aber unbegrenzt. Man beginnt mit hellen Styles:
.card { background: #fff; color: #14161a; }
.card .btn { background: #f0f2f5; }
und fügt anschließend dunkle Entsprechungen unter einer body-Klasse hinzu:
body.dark .card { background: #121212; color: #fff; }
body.dark .card .btn { background: #333; }
body.dark .card .btn:hover { background: #444; }
Weil die dunklen Regeln die hellen überwinden müssen, werden die Selektoren immer spezifischer; body.dark .card .btn:hover umfasst bereits am Anfang vier Bestandteile. Auch die Hex-Werte weichen voneinander ab – in einer Datei steht #121212, in einer anderen #111 und in einem kopierten Komponenten-File #0f0f0f – und es gibt keinen zentralen Ort, an dem der Kontrast automatisch überprüft werden kann. Das grundlegende Skalierungsproblem lässt sich in einem Satz zusammenfassen: Die Überschreibungen nehmen mit der Anzahl der Komponenten zu, während die Tokens mit der Anzahl der Rollen zunehmen, wobei sich die meisten Design-Systeme auf etwa 12 bis 20 Rollen einpendeln.
Zwei separate Stylesheets
Das Laden eines hellen und eines dunklen Stylesheets über Media-Attribute erscheint effizient:
<link rel="stylesheet" href="light.css" media="(prefers-color-scheme: light)">
<link rel="stylesheet" href="dark.css" media="(prefers-color-scheme: dark)">
Es wird nicht verhindert, dass der zweite Download erfolgt – genau hier machen die Leute den Fehler. Wie der web.dev-Artikel zu prefers-color-scheme erläutert, wird die Stylesheet-Datei, deren Media-Abfrage nicht zutrifft, dennoch heruntergeladen, allerdings mit der niedrigsten Priorität, sodass sie nicht mit den Ressourcen konkurrieren kann, die die Seite derzeit benötigt. Der Vorteil ist ein kürzerer kritischer Pfad, nicht weniger Bytes. Zudem liest das media-Attribut nur die Einstellung des Betriebssystems, wodurch keine manuelle Umstellung darauf Einfluss nehmen kann; die beiden Dateien neigen im Laufe der Zeit dazu, auseinanderzudriften; und Bundler können das Paar falsch verarbeiten, wie in einem Vite-Problem beschrieben.
Rang 6: Eine reine CSS-Umstellung mit :has()
Ein visuell verstecktes Kontrollkästchen und sein Etikett können als Schalter dienen:
<input type="checkbox" id="theme" class="sr-only">
<label for="theme">Dark mode</label>
Die Wurzelelemente reagieren anschließend auf den Zustand des Kontrollkästchens mithilfe von :has():
html:has(#theme:checked) {
color-scheme: dark;
--bg-surface: #1b1f27;
--text-1: #e8e6e3;
--border: #2b313c;
}
Dafür ist tatsächlich kein JavaScript erforderlich, und :has() erreichte am 2026-06-19 den Baseline-Zustand „weit verbreitet verfügbar“. Zwei Punkte sind wichtig. Erstens sollten die Farbtöne genauso wie color-scheme geändert werden, wie es im Beispiel der Fall ist. Zweitens existiert der Zustand nur im DOM, sodass bei jedem Seitenaufruf von vorne begonnen wird und nichts gespeichert wird. Zudem lässt sich damit nicht ordentlich ein Drei-Zustände-Modell darstellen; dafür wären Radio-Buttons sowie weitere Selektor-Optionen nötig. Es eignet sich gut für Demonstrationen, CodePen-Projekte oder Ein-Seiten-Dokumente, aber nicht für Produkte.
Rang 5: Tailwinds dark:-Variante
Die dark:-Variante ist nicht falsch, sie platziert die Entscheidungen bezüglich der Farben jedoch an der falschen Stelle: in den Templates, die bei jedem Aufruf wiederholt werden.
<div class="bg-white dark:bg-zinc-900
text-zinc-900 dark:text-zinc-100
border-zinc-200 dark:border-zinc-800
hover:bg-zinc-50 dark:hover:bg-zinc-800">
Das Ergebnis sind lange Klassenzustände, die unübersichtlich werden. Zudem kann kein Skript diese überprüfen, da die dunklen Werte in den Templates verstreut sind. Ein zusätzliches Theme wie hoher Kontrast oder eine Markenfarbgebung verstärkt die Markup-Struktur anstatt nur eine weitere Schicht hinzuzufügen. Es hilft auch nicht bei der von dem Browser generierten Benutzeroberfläche und erfordert weiterhin ein separates Blockierskript, um Flackern zu vermeiden.
Die Lösung liegt in Tailwind selbst. Weisen Sie die Theme-Farben auf CSS-Variablen zu und ändern Sie diese einmal; anschließend verwenden Sie bg-surface ohne das Präfix dark:. Beginnen Sie mit der Standardimport-Methode:
@import "tailwindcss";
Definieren Sie anschließend eine benutzerdefinierte Variante, weisen Sie die Theme-Farben auf die entsprechenden Variablen zu und geben Sie jedem Theme eigene Wertebereiche für diese Variablen:
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));@theme {
--color-surface: var(--surface);
--color-content: var(--content);
}:root { --surface: #f4f5f7; --content: #14161a; color-scheme: light; }
[data-theme="dark"] { --surface: #1b1f27; --content: #e8e6e3; color-scheme: dark; }
[data-theme="hc"] { --surface: #000000; --content: #ffffff; color-scheme: dark; }
Der :where()-Wrapper ist absichtlich so gestaltet. Er verleiht der dunklen Variante keine zusätzliche Spezifität, sodass die dark:-Utilities niemals versehentlich unverwandte Styles überschreiben. Die Hintergrundkontrast-Theme fügen lediglich eine Zeile hinzu, anstatt jeden Template durchlaufen zu müssen.
Rang 4: Nur Befolgung von prefers-color-scheme
Die einfachste und zuverlässigste Option besteht darin, standardmäßig helle Token zu definieren und diese innerhalb einer Media-Query neu zu definieren:
:root {
color-scheme: light;
--bg-base: #ffffff; --text-1: #14161a;
}
@media (prefers-color-scheme: dark) {
:root {
color-scheme: dark;
--bg-base: #12141a; --text-1: #e8e6e3;
}
}
Es gibt kein JavaScript, keinen Flash und nichts, was initialisiert werden müsste. Dies ist der schnellste Ansatz in der gesamten Liste, und sein einziger Nachteil ist für Anwendungen entscheidend, während er für Inhalte irrelevant ist: Die Benutzer können die Systemeinstellung nicht überschreiben.
Falls noch nie jemand nach einem Schalter gefragt hat, wie es bei Blogs, Dokumentationen, Changelog-Seiten und Marketingseiten üblich ist, können Sie hier aufhören. Für diese Art von Seiten gibt es nichts Besseres in der Liste.
Zwei Details zur Spezifikation sind erwähnenswert:
- Media Queries Level 5 warnt davor, dass die Funktion in Zukunft weitere Werte erhalten könnte – Sepia wird als Beispiel genannt – und empfiehlt, durch Negation zu testen:
(prefers-color-scheme: dark)gegenüber(not (prefers-color-scheme: dark)), anstatt explizitlightabzubilden. - Der Wert
no-preferencewurde entfernt. Er fehlt in der aktuellen Spezifikation und wird von keinem Browser implementiert; ein Benutzer ohne Präferenz entsprichtlight.
Rang 3: light-dark() und sein stilles Fehlerverhalten
light-dark() verringert die Größe einer Token-Datei in etwa um die Hälfte, da eine einzige Deklaration beide Werte enthält:
:root { color-scheme: light dark; } /* REQUIRED */
.card {
background: #fff; /* fallback for old browsers */
background: light-dark(#fff, #1b1f27);
color: light-dark(#14161a, #e8e6e3);
border-color: light-dark(#e2e5ea, #2b313c);
}
/* a manual override becomes ONE property write */
[data-theme="dark"] { color-scheme: dark; }
[data-theme="light"] { color-scheme: light; }
Das ist ein vollständiges Themensystem ohne jeglichen @media-Block oder eine zweite :root-Regel. Eine manuelle Übernahme beschränkt sich auf die Einstellung von color-scheme am Wurzelelement.
Doch dieser Ansatz kostet Teams Stunden. Das Durchlaufen mehrerer Varianten in Chromium 141 mit dem Farbschema auf beiden Wegen erzeugt drei wichtige Erkenntnisse:
- Ohne
color-schemehatlight-dark()keinerlei Wirkung. Auf einem dunklen System gibt es stillschweigend die helle Farbe zurück, und im Konsole-Ausgabe wird nichts auf das Problem hingewiesen. Das ist der häufigste Fehler beilight-dark(), und er bleibt unsichtbar, bis jemand auf einem dunklen System ihn meldet.
color-scheme: dark auf einem Element wird dieses unabhängig von den Einstellungen des Betriebssystems das zweite Argument verwenden. Deshalb besteht eine manuelle Umstellung lediglich aus dem Schreiben einer einzigen Eigenschaft, anstatt aus einem Wechsel der Klasse zusammen mit einer parallelen Satz von Regeln.color-scheme: dark auf ein Element außerhalb der Wurzelebene erhält dieses kein dunkles Hintergrundbild. Es ändert zwar die Systemfarben sowie die eingebauten Steuerelemente, doch der Hintergrund des Canvas folgt nur der Einstellung der Wurzelebene. Dies ist das am häufigsten vorkommende Missverständnis bezüglich dieser Eigenschaft.Zur Überprüfung Ihrer eigenen Farbpalette berechnet Chromium im Dunkelmodus die Systemfarbe des Canvas als rgb(18, 18, 18), was #121212 entspricht – derselbe Grundfarbton, den Material für Dunkelthemen vorschlägt.
Bevor Sie sich darauf verlassen, sollten Sie folgende Einschränkungen berücksichtigen:
- Es handelt sich um einen Baseline-Wert, der als „neu verfügbar“ und nicht als „weit verbreitet verfügbar“ eingestuft wird: Chrome und Edge 123, Firefox 120 sowie Safari 17.5 – wobei das Datum der Neuverfügbarkeit 2024-05-13 liegt, was den Zeitpunkt für eine weit verbreitete Verfügbarkeit auf etwa 2026-11-13 setzt.
- Er führt nicht zu einem sanften Fallback. Browser ohne Unterstützung werfen die gesamte Deklaration als ungültig weg, daher sollte man immer zuerst einen einfachen Fallback für dieselbe Eigenschaft bereitstellen, wie im Beispiel gezeigt.
- Es handelt sich um einen Farbwert, weshalb er nicht als Bedingung in Media-Queries verwendet werden kann. Seine Verwendung innerhalb von Deklarationen in einem
@media-Block ist hingegen unproblematisch; das sind zwei verschiedene Dinge. - Bildargumente, wie z. B.
light-dark(url(a.png), url(b.png)), kamen laut den Kompatibilitätsdaten zum Zeitpunkt der Erstellung erst in Chrome 150, Firefox 150 und Safari 27 hinzu – was jedoch zu neu ist, um darauf vertraut werden zu können.
Eine Dokumentationswarnung: Die Prose-Seite von MDN zu light-dark() listet Chrome 119 und Safari 17.2 auf, während MDNs eigene browser-compat-data-Daten sowie die Baseline API Werte von 123 und 17.5 angeben. Wenn diese Angaben nicht übereinstimmen, sollte man den strukturierten Daten vertrauen und vor dem Zitieren von Versionen die aktuellen Seiten überprüfen.
Rang 2: semantische Token – die Schicht, die alles andere benötigt
Die wichtigste Regel ist, die Rolle zu benennen, die eine Farbe einnimmt, niemals die Farbe selbst. Beginnen Sie mit Primitiven, den rohen Palette-Werten, auf die Komponenten niemals direkt verweisen:
/* primitives: raw values, never consumed by components */
:root {
--gray-0: #ffffff; --gray-50: #f4f5f7; --gray-200: #e2e5ea;
--gray-600: #55606e; --gray-900: #14161a; --gray-950: #12141a;
--blue-500: #3b82f6; --blue-400: #60a5fa;
}
Darauf liegt eine semantische Schicht. Helle Werte sind Standard; ein einziger [data-theme="dark"]-Block ersetzt sie alle. Komponenten verwenden ausschließlich Rolle-Bezeichnungen, daher müssen sie nicht wissen, welches Thema aktiv ist:
/* semantic roles: light is the default */
:root {
color-scheme: light;
--bg-base: var(--gray-0);
--bg-surface: var(--gray-50);
--text-1: var(--gray-900);
--text-2: var(--gray-600);
--border: var(--gray-200);
--accent: var(--blue-500);
--shadow-sm: 0 1px 2px rgb(0 0 0 / 0.08);
}/* one block flips the whole app */
[data-theme="dark"] {
color-scheme: dark;
--bg-base: #12141a; /* grey, not #000 */
--bg-surface: #1b1f27; /* lighter = higher up */
--bg-raised: #232833; /* lighter still */
--text-1: #e8e6e3;
--text-2: #a2acbb;
--border: #2b313c;
--accent: var(--blue-400);
--shadow-sm: 0 1px 2px rgb(0 0 0 / 0.5);
}/* components never know which theme is active */
.card { background: var(--bg-surface); color: var(--text-1); border: 1px solid var(--border); }
Achten Sie auf die Details im dunklen Block: Die Grundfarbe ist Dunkelgrau statt Schwarz, die Oberflächen werden heller, je höher sie liegen, der Akzent wechselt zu einem helleren Schritt, und der Schatten wird stärker, damit er sichtbar bleibt.
Ein einfacher Test zeigt an, ob ein Token-Name gut ist: Können Sie beschreiben, wann er verwendet werden soll, ohne eine Farbe zu nennen? „Hintergrund eines erhöhten Panels“ beschreibt eine Rolle; „Dunkelgrau“ beschreibt einen Farbton. Nur Rollen überstehen einen Themawechsel, da ein Token, der buchstäblich „Dunkelgrau“ heißt, niemals dunkel werden sollte.
Für den Schalter selbst ist ein data-theme-Attribut vorzuziehen gegenüber einer .dark-Klasse. Es kann nativ drei oder mehr Werte enthalten, es kommt nicht in Konflikt mit Hilfsklassen, und seine Änderung erfolgt durch eine einzige Zuweisung an document.documentElement.dataset.theme. Weitere Informationen zur Verwendung von benutzerdefinierten Eigenschaften zur Laufzeit finden Sie im Blogbeitrag zu Themenbildung mit CSS-benutzerdefinierten Eigenschaften.
Rang 1: Lesen eines Theme-Cookies auf dem Server
Die Darstellung des Themes auf dem Server ist die einzige Option, die alle vier üblichen Probleme vermeidet: ein inline-Skript, ein Flash-Effekt, eine Inkonsistenz bei der Hydratierung sowie eine Ausnahme in der Content Security Policy – denn der Server kennt das Theme bereits, bevor er den ersten Byte sendet. In einem Next.js App Router-Projekt importiert das Root-Layout den Cookie-Hilfsprogramm:
// app/layout.tsx
import { cookies } from 'next/headers';
und schreibt das gespeicherte Theme direkt in das html-Element, wobei als Ersatz das Helle verwendet wird:
export default async function RootLayout({ children }) {
const store = await cookies(); // async since Next 15
const theme = store.get('theme')?.value ?? 'light'; return (
<html lang="en" data-theme={theme} style={{ colorScheme: theme }}>
<body>{children}</body>
</html>
);
}
Die Änderung des Themes erfolgt in einer Server-Aktion, die denselben Import erfordert:
// app/actions.ts
'use server';
import { cookies } from 'next/headers';
Die Aktion speichert die Wahl für ein Jahr, wobei dies auf die gesamte Website angewendet wird:
export async function setTheme(theme: 'light' | 'dark') {
const store = await cookies();
store.set('theme', theme, { path: '/', maxAge: 60 * 60 * 24 * 365, sameSite: 'lax' });
}
Die Kosten werden von Next.js selbst dokumentiert:
cookies()ist eine API zum Zeitpunkt der Anfrage, wodurch ein Aufruf in einem Layout oder einer Seite diese Route auf dynamische Darstellung umstellt und das statische Vorausrendern verloren geht.- Mit aktivierten Cache Components verhindert ein Aufruf von
cookies()außerhalb einer<Suspense>-Grenze ebenfalls das Vorausrendern. - HTTP erlaubt es nicht, Cookies nach Beginn des Streamens zu setzen; daher muss das Cookie mit
.setin einer Serverfunktion oder Route-Handler gesetzt werden, niemals während des Renderings.
system-Auswahl dennoch auf der Client-Seite entschieden werden muss. Die praktische Kombination besteht darin, für explizite Auswahlmöglichkeiten das Cookie zu verwenden und für den Systemfall matchMedia.Es gibt einen clientseitigen Hinweis, Sec-CH-Prefers-Color-Scheme, der die OS-Einstellung dem Server mitteilen könnte. Es handelt sich dabei lediglich um einen Entwurf vom WICG, der nur in Chromium enthalten ist und kein Standard darstellt; daher sollte er höchstens als Optimierung betrachtet werden, nicht als allgemeine Lösung.
Vermeidung des Anzeigens des falschen Themas
Dies ist das dritte Problem seit Beginn und das häufigste Defekt im Dunkelmodus bei veröffentlichten Websites. Die Debatte um „die richtige Methode gegen die nachlässige Methode“ erwähnt es in der Regel überhaupt nicht. Die Zeitachse zeigt, warum ein verzögert ausgelöster Skript zu spät kommt:
DEFERRED SCRIPT: [HTML][CSS][PAINT: LIGHT][JS][REPAINT: DARK] <- user sees it
BLOCKING INLINE: [HTML][JS][CSS][PAINT: DARK] <- correct first paint
Das Theme muss vor dem ersten Malen auf dem html-Element gesetzt werden. Die Lösung besteht in einem kleinen inline-Skript im head-Bereich, das vor der Stylesheet-Datei platziert wird:
<head>
<meta charset="utf-8">
<meta name="color-scheme" content="light dark">
<script>
// inline. no src, no defer, no async, no type=module.
(function () {
try {
var s = localStorage.getItem('theme'); // 'light'|'dark'|'system'|null
var dark = s === 'dark' ||
((!s || s === 'system') &&
matchMedia('(prefers-color-scheme: dark)').matches);
var el = document.documentElement;
el.dataset.theme = dark ? 'dark' : 'light';
el.style.colorScheme = dark ? 'dark' : 'light';
} catch (e) { /* storage throws in private mode / sandboxed iframes */ }
})();
</script>
<link rel="stylesheet" href="/app.css">
</head>
Jede Einschränkung in diesem Codeausschnitt ist wichtig. Der Script muss inline und synchron sein, ohne src, defer, async oder einen Modultyp – schließlich würde irgendetwas davon es dem Browser ermöglichen, zuerst auszuführen. Es liest eine dreiwertige Präferenz ein und löst system über matchMedia auf. Gleichzeitig werden sowohl das Attribut als auch colorScheme gesetzt, damit Token und die Browser-Oberfläche übereinstimmen. Zudem wird der Zugriff auf das Speichermedium in try/catch eingebettet, da localStorage in privaten Suchmodi sowie in sandboxierten Iframes Fehler auslöst.
Der eigentliche Preis ist die Sicherheitsrichtlinie: Ein inline-Skript erfordert entweder 'unsafe-inline' oder einen nonce in der CSP. Wenn Ihre Richtlinie weder das eine noch das andere zulässt, sollten Sie den Cookie-Ansatz verwenden.
In Next.js muss man auch auf dem html-Element suppressHydrationWarning verwenden, da die Skripte ihre Attribute vor der Hydratierung durch React ändern und diese dann nicht mehr mit dem Server-Markup übereinstimmen. Wie in der next-themes-Dokumentation erwähnt, gilt dieses Flag nur auf einer Ebene und versteckt daher keine Hydratierungswarnungen an anderen Stellen.
Die Analyse der next-themes-Quelle zeigt, wie unvollständig die Implementierung zur Verhinderung von Flash ist. Es wird ein <script dangerouslySetInnerHTML> gerendert, dessen Inhalt seine eigene script()-Funktion ist, die mit script.toString() in einen String umgewandelt und sofort mit JSON-seriellisierten Argumenten aufgerufen wird. Der Hook useTheme() gibt theme, setTheme, resolvedTheme, systemTheme und themes zurück, wobei theme während der Serverrendering-Phase undefined ist. Rendern Sie Ihren Schalter anhand von resolvedTheme nach einer Überprüfung, ob das Element bereits montiert ist – andernfalls verursacht der Schaltknopf selbst einen Hydratierungsfehler.
color-scheme: die Eigenschaft, die die meisten Websites niemals setzen
Ihre Stylesheet legt fest, wie der Inhalt dargestellt wird, doch der Browser selbst zeichnet die Scrollbalken, die eingebauten Formularelemente sowie das Canvas hinter der Seite. Die CSS Color Adjustment-Spezifikation verlangt, dass der Benutzeragent all diese Elemente dem Farbschema des Elements anpasst:
- die Standardfarben von Scrollbalken und interaktiven Benutzeroberflächen
- das Standardaussehen der Formularelemente
- zusätzliche Benutzeroberflächenfunktionen des Browsers, wie zum Beispiel Unterstriche bei Rechtschreibprüfung
- Systemfarben wie
Canvas,CanvasText,ButtonFace,FieldundAccentColor - das Ergebnis von
light-dark()
Auf dem Wurzelelement steuert das Schema zusätzlich die Farbe der Canvas-Oberfläche sowie die Scrollbalken des Ansichtsfensters. Es muss an drei Stellen deklariert werden. Zunächst eine Meta-Tag, die vom HTML-Parser vor dem Eintreffen jeglicher CSS-Dateien gelesen wird:
<!-- parsed at HTML-parse time, BEFORE any CSS loads -->
<meta name="color-scheme" content="light dark">
Zweitens CSS-Regeln, die die Eigenschaft mit dem entsprechenden Theme-Attribut in Einklang bringen:
:root { color-scheme: light dark; }
[data-theme="dark"] { color-scheme: dark; }
[data-theme="light"]{ color-scheme: light; }
Drittens, falls nötig, ein Mechanismus, der sicherstellt, dass ein bestimmtes Widget unabhängig vom Theme hell bleibt:
/* force a widget to stay light regardless */
.brand-widget { color-scheme: only light; }
Die Meta-Tag ist nicht überflüssig. Der Abschnitt im HTML-Standard zu den Meta-Tags für das Farbschema existiert genau deshalb, damit der Browser den Hintergrund der Seite sofort im richtigen Schema darstellen kann, ohne auf Stylesheets warten zu müssen. Die CSS-Eigenschaft ist erst nach dem Herunterladen und Auswerten des Stylesheets bekannt; diese Lücke führt zu einem kurzen weißen Hintergrund. Der Standard erlaubt außerdem maximal ein solches Meta-Element pro Dokument.
Zwei weitere Fallstricke:
- Die Eigenschaft und die Media-Abfrage stehen in keinem Zusammenhang. Die Deklaration von
color-scheme: darkveranlasst niemals dazu, dassprefers-color-scheme: darkzutrifft; daher wird Code, der von einem Wert auf den anderen schließt, falsch funktionieren.
only weist den Browser an, das Schema des Elements nicht zu überschreiben. In der Praxis verhindert es damit, dass Chrome auf Android sein automatisches Dunkelthemen-Setting anwendet. Seine Kompatibilitätsgeschichte ist ungewöhnlich: Es wurde in Chrome 81 hinzugefügt, in 85 entfernt und in 98 wieder eingeführt.Drei Zustände anstelle eines Booleschen Werts
Sobald es sich um einen booleschen Wert handelt, verschwindet die Option „Mein Betriebssystem befolgen“ und der Benutzer kann sie nicht wiederherstellen. Die Einstellung benötigt drei Werte: hell, dunkel und System. Beginnen Sie mit einem Speicher-Schlüssel sowie einer Liste von Medienabfragen:
const STORAGE_KEY = 'theme';
const mq = matchMedia('(prefers-color-scheme: dark)');
Der Rest der Logik wendet die Einstellung an, speichert sie, liest sie mit system als Standard wieder aus und hört nur dann auf Betriebssystemänderungen zu, wenn system ausgewählt ist:
function apply(pref) { // 'light' | 'dark' | 'system'
const dark = pref === 'dark' || (pref === 'system' && mq.matches);
const el = document.documentElement;
el.dataset.theme = dark ? 'dark' : 'light';
el.style.colorScheme = dark ? 'dark' : 'light';
}function setPreference(pref) {
try { localStorage.setItem(STORAGE_KEY, pref); } catch (e) {}
apply(pref);
}function getPreference() {
try { return localStorage.getItem(STORAGE_KEY) || 'system'; }
catch (e) { return 'system'; }
}// keep following the OS, but ONLY while 'system' is the chosen preference
mq.addEventListener('change', () => {
if (getPreference() === 'system') apply('system');
});apply(getPreference());
Verwenden Sie addEventListener auf der MediaQueryList, nicht addListener. MediaQueryList erbt nun von EventTarget, und addListener sowie removeListener sind veraltet, obwohl viele Online-Beispiele sie weiterhin verwenden. next-themes behält die veralteten Methoden absichtlich bei, wobei dazu ein Quellkommentar vorhanden ist, um ältere Safari-Versionen zu unterstützen.
Die Steuerung sollte eine Gruppe von drei Radio-Buttons sein, nicht ein Checkbox, da drei Zustände drei Eingabefelder erfordern.
Vermeidung von Farbverlaufen beim Wechsel
Falls die Elemente auf der Seite Farbübergänge haben, animiert das Wechseln der Themen Hunderte von Eigenschaften gleichzeitig, wodurch die Seite sichtbar verschwimmt. Die von next-themes in seiner disableAnimation-Option verwendete Lösung injiziert einen temporären Stil, der alle Übergänge deaktiviert:
function disableTransitionsTemporarily(nonce) {
const css = document.createElement('style');
if (nonce) css.setAttribute('nonce', nonce);
css.appendChild(document.createTextNode(
`*,*::before,*::after{ transition: none !important }`
));
document.head.appendChild(css);
Es wird eine Funktion zurückgegeben, die die Übergänge wieder aktiviert, und ein swapTheme-Hilfsfunktion kümmert sich um den Wechsel zwischen den beiden Themen:
return () => {
// Deliberate forced synchronous reflow: commit the new colours
// WHILE transitions are still off.
(() => window.getComputedStyle(document.body))();
// Remove on a later task, after the flush has committed.
setTimeout(() => { document.head.removeChild(css); }, 1);
};
}function swapTheme(next) {
const enable = disableTransitionsTemporarily();
apply(next);
enable();
}
Der Aufruf von getComputedStyle wirkt wie tote Code und wird oft entfernt. Tatsächlich handelt es sich dabei um eine absichtliche, erzwungene synchrone Aktualisierung des Stils, die die neuen Farben bereits dann festlegt, während die Übergänge noch deaktiviert sind. Die anschließende Entfernung wird mit setTimeout auf eine spätere Aufgabe verschoben, nachdem die Aktualisierung bereits gewirkt hat. Ohne diese erzwungene Neustilisierung sowie die verzögerte Entfernung ist weiterhin ein teilweiser Verlauf der Übergänge sichtbar.
Falls Sie den Wechsel lieber als sichtbares Effekt darstellen möchten, unterstützt die View Transitions API den bekannten kreisförmigen Übergang. Baseline ist seit dem 14.10.2025 mit Chrome 111, Safari 18 und Firefox 144 verfügbar. Eine Voraussetzung, die leicht übersehen wird, ist: Deaktivieren Sie die Standardanimationen bei den Root-Snaps und setzen Sie den Blend-Modus beim alten Snapshot fest:
::view-transition-old(root) { animation: none; mix-blend-mode: normal; }
::view-transition-new(root) { animation: none; }
Achten Sie auch auf Benutzer, die weniger Bewegung wünschen:
@media (prefers-reduced-motion) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) { animation: none !important; }
}
Entfernen Sie mix-blend-mode: normal nicht aus dem alten Snapshot. Bei der Standard-Blend-Methode plus-lighter wird der Bildschirm während des Übergangs von dunkel zu hell kurz milchweiß, was wie ein Flackern aussieht und als solches gemeldet wird.
Was funktioniert immer noch nicht nach dem Token-Wechsel
Bilder und SVG
Ein <picture>-Element mit media="(prefers-color-scheme: dark)" folgt ausschließlich der Einstellung des Betriebssystems. Es kann weder Ihr Theme-Attribut noch Ihren color-scheme erkennen, wodurch ein manueller Wechsel dazu führt, dass Bilder nicht mit dem Rest der Benutzeroberfläche synchron sind – ein häufig vorkommender Fehler. Hintergrundbilder, die innerhalb Ihres dunklen Token-Blocks deklariert sind, folgen dem Wechsel hingegen. Bei SVG funktioniert currentColor in eingebetteten <svg>-Elementen sowie in <svg><use href="…">-Elementen, aber nicht bei SVGs, die über <img src> oder CSS url() geladen werden, da es sich dabei um separate Dokumente handelt, die niemals Ihren color erben. Legen Sie entweder prefers-color-scheme-Abfragen direkt in die SVG-Datei selbst oder verwenden Sie mask-image zusammen mit background-color: currentColor.
Iframes
Laut den Spezifikationen zur Farbanpassung muss der Browser bei einem Unterschied zwischen dem Farbschema eines Iframes und dem des Wurzelelements des eingebetteten Dokuments ein opakes Canvas im Canvas-Element des eingebetteten Dokuments anstelle eines transparenten zeichnen. In der Praxis entsteht dadurch ein weißer Rechteck auf einer dunklen Seite. Durch Angabe eines color-scheme-Werts für das <iframe>-Element wird dieses erste Canvas korrigiert, doch der eigene CSS-Code eines Dokuments aus einem anderen Ursprung ist weiterhin nicht zugänglich. YouTube, Stripe Elements, Disqus und Turnstile bieten jeweils eine eigene Theme-Option an; es gibt keine Lösung auf CSS-Ebene.
theme-color
Die Unterstützung für das theme-color-Meta-Tag ist viel schwächer, als viele annehmen. Firefox unterstützt es auf keiner Plattform; Chrome für Desktop seit Version 73 wendet es nur auf installierte PWAs an; Safari übernahm es in Version 15, beachtet es aber ab Safari 26 nur in installierten Webanwendungen. MDN listet es als eingeschränkt verfügbar, nicht als Baseline-Status. Die Spezifikation erlaubt es außerdem den Browsern, die Farbe nach eigenem Ermessen anzupassen – beispielsweise zu verdunkeln, um einen ausreichenden Kontrast zu gewährleisten – daher sollte man sich nicht auf eine exakte Darstellung verlassen.
Erzwungene Farben und Kontrasteinstellungen
In dem Hochkontrastmodus von Windows (forced-colors: active) übernimmt der Browser die Steuerung von Eigenschaften wie background-color, color, border-color, outline-color, text-decoration-color sowie den SVG-Eigenschaften fill und stroke. Sowohl box-shadow als auch text-shadow werden auf none gesetzt, und color-scheme bleibt auf light dark festgelegt. Jede Erhebung, die von Schatten abhängt, verschwindet, weshalb sie durch Rahmen in Systemfarben ersetzt werden sollte:
@media (forced-colors: active) {
/* your shadow-based elevation is gone — replace it */
.card { border: 1px solid CanvasText; box-shadow: none; }
.btn { border: 1px solid ButtonText; }
}
Welche Systemfarbe ein Element erhält, hängt von seiner ursprünglichen HTML-Semantik ab und nicht von seiner ARIA-Rolle – daher erhält ein <div role="button"> keine ButtonText-Eigenschaft. Laut MDN sollte man kein separates Design für den Hochkontrastmodus erstellen, sondern lediglich kleine Anpassungen zur Lesbarkeit vornehmen.
Eine weitere Achse wird oft völlig übersehen: prefers-contrast mit den Werten no-preference, more, less und custom. Baseline ist seit dem 31.05.2022 weit verbreitet und unabhängig vom Farbschema. Dunkle Themen, die niemals more berücksichtigen, stellen ein häufiges Barrierefreiheitsproblem dar.
Entwurf der dunklen Palette
Nutzen Sie Dunkelgrau statt Schwarz
Die Richtlinien von Material empfehlen für dunkle Hintergründe und Oberflächen Dunkelgrau statt Schwarz, da Grau Schatten sichtbar hält und die Augenbelastung durch helles Textmaterial verringert. In Googles Codelab zu dunklen Themen wird noch auf die Hintergrundfarbe hingewiesen: Reiner #FFFFFF-Text auf einem dunklen Hintergrund kann verschwimmen oder „bluten“ und erscheint vibrierend, was die Lesbarkeit beeinträchtigt.
Sagen Sie das vorsichtig. Die häufig wiederholte Ansicht, dass reines Schwarz zu einer „Halation“ führt, scheint durch keine kontrollierten Studien gestützt zu werden. Bestätigt wird hingegen der Ausblutungseffekt sowie das Vibrationseffekt bei rein weißem Text, und die Wahl von Grau statt Schwarz wird durch die Sichtbarkeit von Schatten sowie den Augenbelastungsfaktor gerechtfertigt.
Höhe durch Helligkeit ausdrücken
Schatten funktionieren bei dunklen Themen schlecht, weshalb Material durch Helligkeitserhöhung sowie eine leicht größere Farbvielfalt der Oberflächen je höher sie liegen, ausgleicht. color-mix() macht es einfach, dies anhand einer einzigen Basisfarbe zu erzeugen:
[data-theme="dark"] {
--surface-1: #12141a;
--surface-2: color-mix(in oklab, var(--surface-1) 92%, white);
--surface-3: color-mix(in oklab, var(--surface-1) 84%, white);
--surface-4: color-mix(in oklab, var(--surface-1) 76%, white);
}
color-mix() steht seit dem 09.11.2025 in der Baseline-Version weit verbreitet zur Verfügung. Beachten Sie, dass das Elevation-Overlay-System von Material Design 2 veraltet ist: In Googles Dokumentation heißt es, die Overlays wurden durch das tonale Surface-Color-System ersetzt und werden nicht mehr gewartet. Material 3 verwendet Rollen von surfaceContainerLowest bis surfaceContainerHighest, sowie surfaceDim und surfaceBright. Eine Referenz, die eine alte Tabelle mit 5 Prozent bei 1dp bis 16 Prozent bei 24dp anführt, bezieht sich auf ein veraltetes System.
Akzente entfärben
Sättigte Mitteltöne erzeugen Schwingungen auf dunklen Oberflächen. OKLCH macht die Anpassung systematisch: Sein Helligkeitskanal ist wahrnehmungsbedingt gleichmäßig, sodass numerisch gleiche Schritte auch visuell gleichmäßig aussehen – genau hier versagt HSL, weshalb dunkle HSL-Gradienten in der Mitte trüb werden. Ein hellerer, weniger chromatischer Akzent für das dunkle Design sieht so aus:
:root { --accent: oklch(0.55 0.18 255); } /* darker tone on light bg */
[data-theme="dark"] { --accent: oklch(0.72 0.14 255); } /* lighter, less chroma */
Contrast für das dunkle Design erneut überprüfen
Eine Palette, die im Hellen Modus den Kontrast erfüllt, sagt nichts über das Dunkelmodus aus. Die WCAG 2.x-Formel für die relative Helligkeit ist kurz genug, um in einem Skript unterzubringen:
const lum = ([r, g, b]) => {
const f = v => (v /= 255) <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
};
Der Kontrastverhältnis teilt anschließend die hellere Helligkeit durch die dunklere, wobei jeweils 0,05 abgezogen wird:
const contrast = (a, b) => {
const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
};
Durch Ausführen dieser Überprüfung bei jeder Farbe, die zur Textanzeige verwendet wird, werden Probleme aufgedeckt, die dem Auge entgehen könnten. Betrachten wir ein warmes Papierfarbthema, bei dem alles auf dem Bildschirm in Ordnung aussah: Eine Bernsteinfarbe wies ein Verhältnis von 2,06:1 auf, eine für Syntax-Highlighting verwendete Rotfarbe 2,80:1 und eine für Zahlen genutzte Goldfarbe 2,50:1. Alle drei Farben wurden visuell als geeignet befunden, erfüllen aber dennoch nicht die AA-Kriterien.
Die Formel von WCAG 2.x weist einen bekannten Schwachpunkt auf: Sie behandelt Helle auf Dunklem und Dunkles auf Hellem identisch, sodass ein dunkles Farbschema zwar gute Werte erzielen kann, aber dennoch blendend wirken kann. Aus diesem Grund wurde APCA entwickelt, doch APCA ist nicht Teil von WCAG 2.2 und gilt bisher nirgends als Norm.
Was die Forschung über Dark Mode und Lesbarkeit sagt
Diese Aussage wird oft falsch dargestellt. Die Forschungszusammenfassung vom Nielsen Norman Group besagt:
- Bei Personen mit normalem Sehvermögen zeigte sich in Studien von Piepenbrock et al. (2013, Ergonomics) sowie Dobres et al. (2017, Applied Ergonomics) in allen Messkriterien eine bessere Leistung im Helle-Modus. Der Vorteil wird größer, je kleiner die Schriftgröße ist. Die Erklärung liegt im optischen Bereich: Dunkler Text auf einem hellen Hintergrund erzeugt mehr Licht, die Pupille zieht sich zusammen, und eine kleinere Pupille bedeutet weniger sphärische Abweichungen sowie eine größere Bildtiefe.
- Bei Personen mit eingeschränktem Sehvermögen stellten Legge et al. (1985, Vision Research) fest, dass alle sieben Teilnehmer mit trüben Augenmergeln, insbesondere Katarakten, im Dunkelmodus schneller lesen konnten.
- Langfristig deuten Aleman et al. (2018, Scientific Reports) darauf hin, dass eine anhaltende Exposition gegenüber dem Helle-Modus durch Verdünnung der Choroid mit Myopie in Verbindung stehen könnte.
- Die praktische Empfehlung ist, den Nutzern die Möglichkeit zu geben, im Dunkelmodus zu arbeiten, falls sie dies wünschen.
Die ehrliche Einschätzung ist, dass der Dunkelmodus persönlichen Präferenzen und bestimmten Zugänglichkeitsanforderungen dient; er führt bei der allgemeinen Bevölkerung keine nachgewiesene Verbesserung der Lesbarkeit. Das ist der stärkste Argument für eine Steuerung in drei Zuständen anstelle dessen, den Dunkelmodus standardmäßig zu verwenden.
Kernpunkte
- Betrachten Sie den Dunkelmodus als drei Probleme: Auswahl des Themas, Anpassung der Farben und Anwendung der Wahl vor dem ersten Zeichnen.
- Nennen Sie die Tokens nach ihren Funktionen, wechseln Sie sie in einem einzigen
data-theme-Block um und halten Siecolor-schemeim Wurzelbereich in Einklang damit, unterstützt durch die Meta-Tags. - Entscheiden Sie sich für das Theme vor dem ersten Zeichnen – entweder mit einer vom Server gelesenen Cookie oder mit einem blockierenden Inline-Skript – und akzeptieren Sie den damit verbundenen Kompromiss bezüglich der CSP.
- Bieten Sie hell, dunkel und System als drei Zustände an und passen Sie sich nur OS-Änderungen an, wenn der Systemzustand gewählt ist.
prefers-color-scheme anstelle einer Token-Schicht die schnellste und einfachste Lösung.filter: invert() auf einer von Ihnen kontrollierten Seite – dadurch wird die Wurzel zum Container für jedes feste Element.Zusätzliche Literatur
- Zeilenlänge, Abstandsskalen, dunkle Oberflächen, Schatten und Fokusringe in CSS – Erlernen Sie die grundlegenden CSS-Prinzipien hinter ansprechenden Benutzeroberflächen: zeilenlängenbasierte Einstellungen, eine 4px-Abstandsskala, mehrschichtige dunkle Oberflächen, mehrschichtige Schatten sowie sichtbare Fokusringe.
- Drift-Free Countdown-Timer in React: Von setTimeout bis reines CSS — Vergleichen Sie setTimeout, requestAnimationFrame sowie eine JavaScript-freie CSS-Technik für Countdowns in React, einschließlich des Tricks mit einzelnem Verzögerungswert, der die Ziffern synchron hält.
- Sicheres Einsetzen moderner CSS: Anchors, Grid-Lanes, Scope und light-dark() — Erfahren Sie, wie Ankerpositionierung, Popover-Funktionen, Grid-Lanes-Masonry-Layouts, Ansichtsübergänge, light-dark() sowie @scope JavaScript-Bibliotheken ersetzen können und wie man jede dieser Methoden mit Fallback-Lösungen einsetzt.
- Woher kommen CSS-Werte, wenn sie auf „None“ gesetzt werden: Wie das Erbe funktioniert — Erfahren Sie, wie Browser entscheiden, ob eine Eigenschaft vererbt wird, warum Kinder den berechneten Wert des Elternteils erhalten, und wie „inherit“ sowie „initial“ Ihnen eine explizite Kontrolle geben.