Design wechseln

Tailwind Dark Mode: class vs. data-theme im Vergleich

Auf dem Bildschirm blinkt dark:bg-gray-900 – und die Frage bleibt: Tailwind Dark Mode mit class oder data-theme?

Beide Varianten haben Zeit gekostet. In der Doku findet man meist nur Fragmente; zusammengesetzt fehlt oft das Gesamtbild. Irgendwann habe ich offizielle Docs, GitHub-Diskussionen und den Quellcode beliebter UI-Bibliotheken durchgearbeitet – und die Unterschiede klarer gesehen. Dieser Artikel fasst Stolpersteine und Abwägungen zusammen.


Drei Dark-Mode-Strategien in Tailwind

Wichtig vorweg: Tailwind bietet drei Strategien – nicht nur zwei.

Media-Strategie: Systemeinstellung folgen

Media ist der Standard – viele kennen das gar nicht. Über prefers-color-scheme reagiert das Layout auf die Systempräferenz.

<!-- Keine Extra-Konfiguration – folgt dem System -->
&lt;div class="bg-white dark:bg-gray-900"&gt;
  Inhalt wechselt automatisch mit der Systemeinstellung
&lt;/div&gt;

Vorteil: null Aufwand, oft passendes Erlebnis. Nachteil: keine manuelle Wahl – wer tagsüber Dunkelmodus will, ist schlechter bedient.

Class-Strategie: manuell steuern

Bei der Class-Strategie setzen Sie auf dem Elternelement (meist &lt;html&gt;) die Klasse .dark – dann greifen alle dark:*-Utilities.

&lt;html class="dark"&gt;
  &lt;body class="bg-white dark:bg-gray-900"&gt;
    Dunkelmodus aktiv
  &lt;/body&gt;
&lt;/html&gt;

Das ist der verbreitetste Weg: viel Doku, viele Libraries.

Data-theme-Strategie: semantisches Attribut

Statt einer Klasse nutzen Sie data-theme="dark". Semantik ist klarer; Mehrfach-Themen sind einfacher.

&lt;html data-theme="dark"&gt;
  &lt;body class="bg-white dark:bg-gray-900"&gt;
    Dunkelmodus aktiv
  &lt;/body&gt;
&lt;/html&gt;

data-theme="oled" oder data-theme="sepia" – beliebig erweiterbar. Praktisch, wenn mehr als Hell/Dunkel nötig ist.


Class-Strategie im Detail

Funktionsweise

Liegt .dark auf einem Vorfahren, werden dark:*-Styles aktiv.

Tailwind v3 – in der Config:

// tailwind.config.js
module.exports = {
  darkMode: 'class',
  // ...
}

Erzeugtes CSS etwa so:

.dark .dark:bg-gray-900 {
  background-color: #111827;
}

Tailwind v4 – CSS-first mit @custom-variant:

/* global.css */
@import 'tailwindcss';
@custom-variant dark (&:where(.dark, .dark *));

:where() hält die Spezifität niedrig – wichtig für spätere Overrides.

JavaScript zum Umschalten

function getTheme() {
  return localStorage.getItem('theme') ||
    (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
}

function setTheme(theme) {
  localStorage.setItem('theme', theme);
  document.documentElement.classList.toggle('dark', theme === 'dark');
}

setTheme(getTheme());

Lesen aus localStorage, Fallback System, speichern und Klasse setzen – reicht für die meisten Fälle.

Flash beim Laden vermeiden

Kurz hell, dann dunkel: HTML rendert, bevor JS läuft. Lösung – synchrones Script im &lt;head&gt;:

&lt;head&gt;
  &lt;script&gt;
    if (localStorage.theme === 'dark' ||
        (!('theme' in localStorage) &&
         window.matchMedia('(prefers-color-scheme: dark)').matches)) {
      document.documentElement.classList.add('dark');
    }
  &lt;/script&gt;
&lt;/head&gt;

Kein defer, kein async.

Vor- und Nachteile

Vorteile:

  • Einfach, schnell einsteigen
  • Viele Community-Beispiele und Framework-Anleitungen
  • Gute Integration mit next-themes u. Ä.
  • Etwas höhere Spezifität durch Klassen

Nachteile:

  • .dark ist nicht selbsterklärend
  • Mehrere Themes = mehrere Klassen, unübersichtlicher
  • Mit CSS-Variablen oft Extra-Anpassung

Data-theme-Strategie im Detail

Funktionsweise

Attribut-Selektoren statt Klassen. Tailwind v4:

@import 'tailwindcss';
@custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *));

Erzeugtes CSS:

[data-theme='dark'] .dark:bg-gray-900 {
  background-color: #111827;
}

Tailwind v3 – Array-Konfiguration:

module.exports = {
  darkMode: ['selector', '[data-theme="dark"]'],
}

Mit CSS-Variablen

Data-theme und CSS-Variablen passen gut zusammen:

:root {
  --background: 0 0% 100%;
  --foreground: 222 84% 5%;
}

[data-theme='dark'] {
  --background: 222 84% 5%;
  --foreground: 210 40% 98%;
}

[data-theme='oled'] {
  --background: 0 0% 0%;
  --foreground: 0 0% 100%;
}

In Tailwind referenzieren:

module.exports = {
  theme: {
    extend: {
      colors: {
        background: 'hsl(var(--background))',
        foreground: 'hsl(var(--foreground))',
      }
    }
  }
}

data-theme ändern – alle abhängigen Styles folgen, ohne dark: überall.

Praxis bei shadcn/ui

shadcn/ui nutzt standardmäßig data-theme plus Variablen, unterstützt aber auch .dark:

@layer base {
  :root {
    --background: 0 0% 100%;
    --foreground: 222.2 84% 4.9%;
    /* ... */
  }

  .dark,
  [data-theme='dark'] {
    --background: 222.2 84% 4.9%;
    --foreground: 210 40% 98%;
    /* ... */
  }
}

Mit shadcn/ui können Sie beides triggern – eine Variante reicht.

Mehrere Themes

&lt;html data-theme="oled"&gt;
&lt;/html&gt;

&lt;html data-theme="sepia"&gt;
&lt;/html&gt;
function setTheme(theme) {
  localStorage.setItem('theme', theme);
  document.documentElement.dataset.theme = theme;
}

So flexibel ist die Class-Strategie nur mit mehr Aufwand.

Vor- und Nachteile

Vorteile:

  • Klare Semantik (data-theme="dark")
  • Mehrfach-Themen natürlich
  • Starke Kombination mit CSS-Variablen
  • shadcn/ui, daisyUI u. Ä. kompatibel

Nachteile:

  • In v3 Selector selbst konfigurieren
  • Manche Libraries brauchen Anpassung
  • Etwas weniger Doku als class – holt aber auf

Vergleichsmatrix

Niedrig
Class – Implementierung
Einfache Konfiguration
Mittel
Data-theme – Implementierung
Attribut-Selektoren verstehen
Hoch
Class – Community
Umfangreiche Doku
Mittel
Data-theme – Community
Wird verbreiteter
Schwer
Class – Mehrfach-Themen
Mehrere Klassen nötig
Einfach
Data-theme – Mehrfach-Themen
Attributwert ändern
Source: Strategievergleich
DimensionClassData-theme
ImplementierungNiedrigMittel
SemantikMittel (.dark)Hoch (data-theme)
Mehrfach-ThemenSchwerEinfach
CommunityHochMittel
CSS-VariablenExtra-AufwandNatürlich
Tailwind v3darkMode: 'class'darkMode: ['selector', '...']
Tailwind v4@custom-variant@custom-variant
LibrariesKompatibilität prüfenshadcn/ui u. Ä.
SpezifitätEtwas höher (Klasse)Attribut, vergleichbar

Wann Class?

  • Nur Hell/Dunkel
  • Next.js + next-themes
  • Team kennt v3-Config gut
  • Viele Community-Referenzen gewünscht

Wann Data-theme?

  • OLED, Sepia, weitere Themes
  • shadcn/ui oder ähnlich
  • Tiefe Integration mit CSS-Variablen
  • Hohe Anforderung an Semantik

Framework-Integration

Astro

Integration ist unkompliziert – View Transitions sind die typische Falle.

Basis:

import { defineConfig } from 'astro/config';
import tailwindcss from '@tailwindcss/vite';

export default defineConfig({
  vite: {
    plugins: [tailwindcss()]
  }
});

Script im Head (BaseLayout.astro):

&lt;script is:inline&gt;
  const theme = localStorage.getItem('theme') ||
    (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');

  if (theme === 'dark') {
    document.documentElement.classList.add('dark');
    // document.documentElement.dataset.theme = 'dark';
  }
&lt;/script&gt;

Nach View Transition:

&lt;script&gt;
  document.addEventListener('astro:after-swap', () => {
    const theme = localStorage.getItem('theme');
    if (theme === 'dark') {
      document.documentElement.classList.add('dark');
    }
  });
&lt;/script&gt;

Ohne astro:after-swap verliert sich der Modus oft – leicht zu übersehen.

Next.js + next-themes

next-themes kapselt Persistenz, Systemerkennung und Hydration.

npm install next-themes
import { ThemeProvider } from 'next-themes';

export function AppThemeProvider({ children }: { children: React.ReactNode }) {
  return (
    &lt;ThemeProvider
      attribute="class"
      defaultTheme="system"
      enableSystem={true}
      disableTransitionOnChange
    &gt;
      {children}
    &lt;/ThemeProvider&gt;
  );
}

Data-theme: attribute="data-theme".

export default function RootLayout({ children }) {
  return (
    &lt;html lang="de"&gt;
      &lt;body&gt;
        &lt;AppThemeProvider&gt;{children}&lt;/AppThemeProvider&gt;
      &lt;/body&gt;
    &lt;/html&gt;
  );
}
import { useTheme } from 'next-themes';

export function ThemeToggle() {
  const { theme, setTheme } = useTheme();
  return (
    &lt;button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}&gt;
      {theme === 'dark' ? '☀️' : '🌙'}
    &lt;/button&gt;
  );
}

Tailwind v4

@custom-variant

Varianten direkt in CSS:

@import 'tailwindcss';

@custom-variant dark (&:where(.dark, .dark *));
@custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *));

Kein JS-Rebuild nur für Dark-Mode-Config.

@theme und Variablen

@import 'tailwindcss';
@custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *));

@theme {
  --color-primary: oklch(0.65 0.2 150);
  --color-muted: oklch(0.9 0.02 200);
}

[data-theme='dark'] {
  --color-primary: oklch(0.7 0.15 180);
  --color-muted: oklch(0.3 0.02 200);
}
&lt;button class="bg-primary text-white"&gt;Button&lt;/button&gt;

Kein dark:bg-primary-dark nötig.

Hell / Dunkel / System

function setTheme(theme) {
  if (theme === 'system') {
    localStorage.removeItem('theme');
    const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
    document.documentElement.dataset.theme = isDark ? 'dark' : 'light';
  } else {
    localStorage.setItem('theme', theme);
    document.documentElement.dataset.theme = theme;
  }
}

window.matchMedia('(prefers-color-scheme: dark)')
  .addEventListener('change', (e) => {
    if (!localStorage.getItem('theme')) {
      document.documentElement.dataset.theme = e.matches ? 'dark' : 'light';
    }
  });

Best Practices

Empfehlung

  1. Einfaches Projekt: class + kleines Script
  2. shadcn/ui: data-theme + CSS-Variablen
  3. Mehrere Themes: data-theme
  4. Next.js: next-themes, attribute nach Bedarf
  5. Astro: View Transitions nicht vergessen

Anti-Flash (vollständig)

&lt;head&gt;
  &lt;script is:inline&gt;
    (function() {
      const theme = localStorage.getItem('theme');
      const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
      if (theme === 'dark' || (!theme && systemDark)) {
        document.documentElement.classList.add('dark');
        // document.documentElement.dataset.theme = 'dark';
      }
    })();
  &lt;/script&gt;
&lt;/head&gt;

SSR und Hydration

Bei eigenem React-Code Hydration-Mismatch vermeiden – next-themes löst das. Sonst z. B.:

import { useEffect, useState } from 'react';

function useTheme() {
  const [theme, setTheme] = useState('light');
  useEffect(() => {
    setTheme(localStorage.getItem('theme') || 'light');
  }, []);
  return theme;
}

Semantische Variablennamen

/* Empfohlen */
:root {
  --background: ...;
  --foreground: ...;
  --primary: ...;
}

/* Nicht empfohlen */
:root {
  --white: ...;
  --black: ...;
}

Fazit

Class ist ausgereift und für die meisten Projekte mit nur Hell/Dunkel die sichere Wahl. Data-theme ist semantisch klarer und besser für Mehrfach-Themen und CSS-Variablen – besonders mit shadcn/ui.

In Tailwind v4 konfigurieren beide Wege mit @custom-variant schlank. Entscheidend sind Anforderungen und Stack. Und: Astro View Transitions sowie Next.js-Hydration sauber lösen – sonst leidet die UX trotz guter Strategie.


Referenzen

FAQ

Was unterscheidet @custom-variant in Tailwind v4 von der v3-Konfiguration?
Hauptsächlich der Ort: v3 definiert Varianten in tailwind.config.js, v4 deklariert sie in der CSS-Datei mit @custom-variant. Funktional identisch – v4 passt besser zum CSS-first-Ansatz.
Kann man class und data-theme gleichzeitig nutzen?
Ja, aber meist unnötig. Beide lösen dasselbe Problem; beides parallel erhöht die Komplexität. shadcn/ui unterstützt .dark und [data-theme="dark"] nur für unterschiedliche Gewohnheiten – wählen Sie eine Variante.
Zu viele dark:-Modifier – was tun?
CSS-Variablen nutzen: Werte per Attribut umschalten, alle betroffenen Styles aktualisieren sich ohne dark: auf jedem Element.

Vorgehen:
1. Variablen in globals.css mit @theme definieren
2. Werte unter verschiedenen [data-theme] überschreiben
3. Variablen in tailwind.config.js referenzieren

Dann passt sich bg-primary automatisch an.
Dark-Mode-Status in Astro-Projekten geht verloren – warum?
View Transitions rendern beim Seitenwechsel das DOM neu; der Theme-Status kann verschwinden. Lösung: astro:after-swap abhören und Theme erneut setzen:

document.addEventListener('astro:after-swap', () => {
const theme = localStorage.getItem('theme');
if (theme === 'dark') {
document.documentElement.classList.add('dark');
}
});

Diesen Schritt übersehen viele.
Weißer Flash beim Laden – wie vermeiden?
Synchrones Script im &lt;head&gt;, vor dem ersten Paint:

&lt;script&gt;
if (localStorage.theme === 'dark' ||
(!('theme' in localStorage) &&
window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
&lt;/script&gt;

Wichtig: kein defer oder async.

6 Min. Lesezeit · Veröffentlicht am: 28. März 2026 · Aktualisiert am: 13. Juli 2026

Kommentare

Melde dich mit GitHub an, um einen Kommentar zu hinterlassen

Easton BlogEaston Blog