# Tailwind v4 Dark Mode: Klassen-Toggle, Tokens, next-themes

> Tailwind v4 Dark Mode per Klasse: die @custom-variant-Zeile, next-themes in Next.js, warum dark: deinen Toggle ignoriert und Tokens für zwei echte Themes.

- Autor: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Veröffentlicht: 13. Okt. 2026
- Kategorie: [Next.js und Tailwind CSS](https://wingo-ui.com/de/blog/category/nextjs-tailwind)
- Lesezeit: 9 Min.
- Kanonische URL: https://wingo-ui.com/de/blog/tailwind-v4-dark-mode
- Englische Fassung: https://wingo-ui.com/blog/tailwind-v4-dark-mode

## Kurz gesagt

Tailwind v4 Dark Mode folgt standardmäßig dem Betriebssystem: dark:-Utilities werden zu einer prefers-color-scheme-Media-Query kompiliert, also ändert ein Toggle, der eine Klasse setzt, gar nichts, bis du @custom-variant dark (&:where(.dark, .dark *)); in dein CSS schreibst. In Next.js schreibt next-themes diese Klasse dann vor dem ersten Paint auf html, sofern du attribute="class" übergibst, denn sein Default ist ein data-theme-Attribut. Leg Farben in semantische Tokens, die du unter .dark neu definierst: Dann brauchen Komponenten kaum dark:-Klassen, und beide Themes sind bewusst gestaltet.

Du baust einen Mond-Button ein, er setzt `class="dark"` auf `<html>`, und nichts passiert. Oder die Seite wird dunkel, sobald macOS dunkel wird, egal was der Button sagt. Beim Tailwind v4 Dark Mode entscheidet ein einziger Schalter über all das, und der ist aus `tailwind.config.js` in dein CSS gewandert. Wie du den Tailwind Dark Mode einrichten kannst, zeigt dir dieses Tutorial Schritt für Schritt: den Schalter selbst, wie next-themes in Next.js daran andockt, die Checkliste für den Fall, dass es trotzdem nicht klappt, und wie du das dunkle Theme gestaltest, statt es einfach zu invertieren. Dafür nutzt du semantische Tokens und einen Toggle, der schon vom Server richtig gerendert wird.

Alles hier haben wir am 13. Oktober 2026 mit Tailwind CSS 4.3.3, next-themes 0.4.6, Next.js 16.3 und React 19.3 geprüft. Jeder Block mit kompiliertem CSS ist echte Ausgabe von Tailwind 4.3.3.

## Warum folgt Tailwind v4 Dark Mode dem Betriebssystem statt meinem Toggle?

Weil das der Default ist. Ohne Konfiguration kompiliert Tailwind v4 `dark:bg-black` zu einer Media Query:

```css
@media (prefers-color-scheme: dark) {
  .dark\:bg-black {
    background-color: var(--color-black);
  }
}
```

Eine Klasse auf `<html>` kann eine Media Query nicht ändern, dein Toggle hat also keinen Angriffspunkt. Die [Tailwind-Doku zum Dark Mode](https://tailwindcss.com/docs/dark-mode) sagt es in einem Satz: Standardmäßig richtet sich die Variante nach dem CSS-Media-Feature prefers-color-scheme. Willst du selbst steuern, definierst du die Variante in dem Stylesheet neu, das Tailwind importiert:

```css
/* app/globals.css */
@import "tailwindcss";

@custom-variant dark (&:where(.dark, .dark *));
```

Jetzt kompiliert dieselbe Utility zu einem Selektor:

```css
.dark\:bg-black:where(.dark, .dark *) {
  background-color: var(--color-black);
}
```

Kommst du von v3, ersetzt genau diese Zeile `darkMode: "class"`. Tailwind v4 liest `tailwind.config.js` nicht mehr von selbst. Wir haben ein Stylesheet neben einer Config-Datei mit `module.exports = { darkMode: "class" }` kompiliert: Ohne eine Zeile `@config "./tailwind.config.js";` blieb `dark:` eine Media Query. Mit ihr wurde `dark:` zu `:is(.dark *)`, der Form nur für Nachfahren aus der Tabelle unten. Die Config funktioniert über `@config` also weiterhin, aber die `@custom-variant`-Zeile ist kürzer, lässt dich den Selektor wählen und hält die Einstellung dort, wo der Rest deines Themes liegt.

## Welchen Selektor solltest du für die Tailwind v4 Dark Mode Class nehmen?

Nimm `&:where(.dark, .dark *)`, außer du brauchst ein Data-Attribut. Drei Formen sind verbreitet, und sie unterscheiden sich in zwei Details: ob das Element trifft, das die Klasse trägt, und wie viel Spezifität die Variante hinzufügt.

| Variante | Toggle schreibt | Trifft das Element mit der Klasse | Zusätzliche Spezifität |
| --- | --- | --- | --- |
| `&:where(.dark, .dark *)` | `class="dark"` | Ja | Keine |
| `&:is(.dark *)` | `class="dark"` | Nein, nur Nachfahren | Eine Klasse |
| `&:where([data-theme=dark], [data-theme=dark] *)` | `data-theme="dark"` | Ja | Keine |

Die erste und die dritte stammen aus der Tailwind-Doku. Die zweite nutzt das [Stylesheet von shadcn/ui v4](https://github.com/shadcn-ui/ui/blob/main/apps/v4/app/globals.css) (Stand Oktober 2026) und auch unsere eigene `globals.css`. Sie ist in Ordnung, solange die Klasse auf `<html>` sitzt und nichts `<html>` mit `dark:` stylt. Probleme macht sie an zwei Stellen: `<html className="dark:bg-black">` trifft nie, und eine dunkle Insel wie `<section className="dark dark:border-white/10">` bekommt ihren eigenen Rahmen nicht, während ihre Kinder umschalten. Fängst du neu an, nimm die `:where`-Form.

Ein Data-Attribut ist die bessere Wahl, wenn du mehr als zwei Themes hast (`data-theme="dim"`, `data-theme="contrast"`). Eine Klassenliste kann nämlich zwei Theme-Klassen gleichzeitig enthalten, ein Attribut hält immer nur einen Wert.

## Wie verbindest du next-themes mit Tailwind v4 Dark Mode in Next.js?

Übergib `attribute="class"` an den Provider, denn next-themes schreibt standardmäßig ein `data-theme`-Attribut. [next-themes](https://github.com/pacocoursey/next-themes) erledigt drei Aufgaben: Es speichert die Wahl in `localStorage`, folgt dem System, wenn die Wahl `system` ist, und fügt ein kleines Skript ein, das die Klasse setzt, bevor die Seite gezeichnet wird. Provider und Root Layout für Tailwind v4 Dark Mode in Next.js:

```tsx
// app/providers.tsx
"use client";

import type { ReactNode } from "react";
import { ThemeProvider } from "next-themes";

export function Providers({ children }: { children: ReactNode }) {
  return (
    <ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
      {children}
    </ThemeProvider>
  );
}
```

```tsx
// app/layout.tsx
import type { ReactNode } from "react";
import { Providers } from "./providers";
import "./globals.css";

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body className="bg-background text-foreground">
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}
```

Stand Oktober 2026 zeigt die [README von next-themes](https://github.com/pacocoursey/next-themes#with-tailwindcss) noch das Setup für Tailwind v3 (`darkMode: 'selector'` in `tailwind.config.js`) und erwähnt v4 nicht. Das Gegenstück in v4 ist die `@custom-variant`-Zeile von oben, sonst nichts.

Zwei Details übersieht man leicht:

- **`color-scheme` gibt es gratis dazu.** next-themes setzt außerdem `style="color-scheme: dark"` auf `<html>` (die Prop `enableColorScheme`, standardmäßig an). Das macht Scrollbars, Datumsfelder und Autofill dunkel. Ohne next-themes ergänzt du die v4-Utility `scheme-dark` oder `color-scheme: dark` in deinem `.dark`-Block.
- **Der Hintergrund gehört auf `body`.** Setz `bg-background` auf `<body>`, nicht auf `<html>` mit einer `dark:`-Klasse, wegen der Spezifität aus der Tabelle.

Das komplette Projekt-Setup, von `create-next-app` bis zur ersten Komponente, steht in unserem Guide zu einer [Next.js Component Library](https://wingo-ui.com/de/blog/next-js-component-library-setup). Warum `suppressHydrationWarning` auf `<html>` steht und was du tust, wenn die Konsole trotzdem meckert, ist das ganze Thema des Artikels zum [next-themes Hydration Error](https://wingo-ui.com/de/blog/next-themes-hydration-error).

## Tailwind v4 Dark Mode funktioniert nicht: Woran liegt das?

Meist fehlt die Variante, oder sie passt nicht zu dem, was der Toggle schreibt. Geh die Liste der Reihe nach durch; zu jeder Ursache gibt es einen schnellen Check.

1. **Keine `@custom-variant`-Zeile.** Such im kompilierten CSS in den DevTools nach `prefers-color-scheme`. Stehen deine `dark:`-Regeln in dieser Media Query, fehlt die Variante oder sie liegt in einer Datei, die nicht importiert wird.
2. **Variante und Toggle passen nicht zusammen.** Die Variante sucht nach `.dark`, während next-themes `data-theme="dark"` schreibt, oder umgekehrt. Schau dir `<html>` nach einem Umschalten an und vergleich.
3. **Ein übrig gebliebener Media-Block.** Das Stylesheet von `create-next-app` bringt `@media (prefers-color-scheme: dark) { :root { ... } }` mit. Diese Variablen folgen dem Betriebssystem, egal was deine Klasse sagt, also wird die Seite halb dunkel. Verschieb die Werte in einen `.dark`-Block und lösch den Media-Block.
4. **`dark:` auf dem Element, das die Klasse trägt.** Mit `:is(.dark *)` stylt ein Element nur seine Nachfahren. Wechsle zur `:where`-Form oder verschieb die Klasse weiter nach oben.
5. **Tokens zu früh aufgelöst.** Sitzt `.dark` auf einem Container statt auf `<html>`, bilde deine Variablen mit `@theme inline` ab. Ohne `inline` wird `--color-surface` einmal auf `:root` aufgelöst, und ein verschachteltes `.dark` erreicht es nicht. Der Guide zu [Tailwind v4 Design Tokens](https://wingo-ui.com/de/blog/tailwind-v4-design-tokens) zeigt den Unterschied.
6. **Bilder folgen dem Betriebssystem.** `<picture><source media="(prefers-color-scheme: dark)">` ist ebenfalls eine Media Query und ignoriert deinen Toggle. Render beide Bilder und blende eines mit `dark:hidden` und `hidden dark:block` aus.
7. **Aus Strings gebaute Klassen.** `dark:${color}` erreicht den Scanner von Tailwind nie. Schreib vollständige Klassennamen oder gib eine CSS-Variable weiter.
8. **Die Browser-Toolbar folgt dem Betriebssystem.** Ein `themeColor`-Array im `viewport`-Export von Next.js mit Einträgen `media: "(prefers-color-scheme: dark)"` ist eine weitere Media Query. Safari kann dann eine weiße Toolbar über einer Seite zeigen, die jemand auf dunkel umgestellt hat. Nimm eine neutrale Farbe oder aktualisiere das `theme-color`-Meta-Tag aus deinem Toggle.

## Wie gestaltest du Dark Mode, statt ihn zu invertieren?

Gib jeder Farbe eine Rolle und wähl den dunklen Wert jeder Rolle von Hand. In Tailwind v4 heißt das: CSS-Variablen in `:root`, dieselben Namen noch einmal in `.dark` und ein `@theme inline`-Block, der daraus Utilities macht:

```css
/* app/globals.css */
:root {
  --background: oklch(1 0 0);
  --surface: #ffffff;
  --foreground: oklch(0.3211 0 0);
  --muted-foreground: oklch(0.52 0.0234 264.3637);
  --success-soft: #e7f6ec;
  --success-soft-foreground: #166534;
}

.dark {
  --background: oklch(0 0 0);
  --surface: #1a1a1a;
  --foreground: oklch(0.9219 0 0);
  --muted-foreground: oklch(0.7155 0 0);
  --success-soft: #0d2315;
  --success-soft-foreground: #4ade80;
}

@theme inline {
  --color-background: var(--background);
  --color-surface: var(--surface);
  --color-foreground: var(--foreground);
  --color-muted-foreground: var(--muted-foreground);
  --color-success-soft: var(--success-soft);
  --color-success-soft-foreground: var(--success-soft-foreground);
}
```

Das sind die echten Werte aus unserer `app/globals.css`, gekürzt auf sechs Rollen. Eine Komponente, die auf diesen Tokens aufbaut, kommt ganz ohne `dark:`-Klassen aus:

```tsx
export function PaidBadge() {
  return (
    <span className="rounded-full bg-success-soft px-2 py-0.5 text-xs font-medium text-success-soft-foreground">
      Bezahlt
    </span>
  );
}
```

Schau dir an, was die dunklen Werte tun, denn eine automatische Invertierung würde nichts davon leisten:

- **Flächen werden heller, je weiter sie nach vorn kommen.** Die Seite ist schwarz, ein erhöhtes Panel `#1A1A1A`, ein Popover `#262626`. Invertierst du weiße Panels auf einer hellgrauen Seite, bekommst du dunkle Panels auf einer helleren Seite, und das wirkt verkehrt herum.
- **Status-Text ändert sich, Status-Flächen nicht.** Die blassgrüne Fläche wird fast schwarz, und ihr Text geht von `#166534` auf `#4ADE80`, weil dunkelgrüner Text auf dunkler Fläche beim Kontrast durchfällt. Volle Flächen mit weißem Text bleiben in beiden Themes gleich.
- **Schatten wechseln die Technik.** Ein Schlagschatten ist auf Schwarz unsichtbar, also setzt Elevation im Dark Mode stattdessen auf eine beleuchtete Oberkante und eine Haarlinie.

Die [Card](https://wingo-ui.com/components/card) zeigt alle drei. Schalte das Theme der Seite mit dem Toggle im Header um, während du sie anschaust:

> Live-Demo (Card): Schalte das Theme der Seite um: Die Soft Card behält ihre getönte Fläche und ihren lesbaren Titel in beiden Themes, und das Anheben beim Hover wird im Dark Mode vom Schatten zur beleuchteten Kante. Probier sie unter [Card](https://wingo-ui.com/components/card) aus und installier sie mit `npx wingo-ui@latest add card`.

Tones tragen dieselbe Idee in die Props. Jede Wingo-UI-Komponente mit `tone`-Prop färbt sich über fünf Variablen aus [tones](https://wingo-ui.com/components/tones) (`--tone`, `--tone-foreground`, `--tone-soft`, `--tone-soft-foreground`, `--tone-border`), und jede davon zeigt auf ein semantisches Token. Ein Badge mit `tone="success"` stimmt deshalb in beiden Themes ohne eine einzige `dark:`-Klasse, und eine eigene `color`-Prop läuft per `color-mix()` in Richtung `--foreground`, passt sich also ebenfalls an. Das komplette Token-Set mit einem Kontrast-Check, den du in CI laufen lassen kannst, steht im Pillar-Artikel zu [Tailwind v4 Design Tokens](https://wingo-ui.com/de/blog/tailwind-v4-design-tokens).

## Wann solltest du trotzdem dark:-Klassen schreiben?

Für Dinge, die ein Token nicht beschreiben kann. Eine gute Regel: Bräuchten zwei verschiedene Komponenten dieselbe `dark:`-Klasse, sollte sie stattdessen ein Token sein. Übrig bleibt meistens:

- **Assets.** Ein Logo mit schwarzem Schriftzug, ein Screenshot, eine Illustration für eine weiße Seite: Render beide Versionen und nutze `dark:hidden` / `hidden dark:block`.
- **Das Icon des Theme Toggles selbst.** Sonne im hellen, Mond im dunklen Modus: Genau dafür ist `dark:` gedacht, und es hat einen Nebeneffekt, den du weiter unten siehst.
- **Eine einmalige Korrektur.** `dark:opacity-80` auf einem hellen Foto, damit es nicht blendet.

Sammelt eine Komponentendatei `dark:bg-zinc-900 dark:text-zinc-100 dark:border-zinc-800`, fehlt ein Token. Diese Klassen laufen auseinander, sobald jemand das nächste Mal die Palette anpasst.

## Wie baust du einen Tailwind v4 Dark Mode Toggle?

Lies und setz das Theme über next-themes in einer Client Component und zeichne das Icon mit `dark:`-Klassen. Was einen guten Toggle von einem flackernden unterscheidet: Der Server kennt das Theme nicht, also muss alles, was der Toggle vor der Hydration rendert, aus CSS kommen. Genau das leistet ein Icon aus `dark:`-Klassen: Das Server-HTML enthält beide Zustände, und die Klasse, die next-themes vor dem Paint setzt, wählt einen davon aus.

Unser [Theme Toggle](https://wingo-ui.com/components/theme-toggle) ist so gebaut. Sein erster Render zeichnet Sonne und Mond aus `dark:`-Klassen, und die animierte Version übernimmt, sobald React das Theme kennt. Wir haben die Vorschau mit blockierten JavaScript-Bundles geladen und nur das Inline-Theme-Skript erlaubt: Das Server-HTML zeigte auf einem dunklen Gerät den Mond und auf einem hellen die Sonne. Es gibt ihn als Icon Button, als Segmented Control oder als Dropdown, und er schaltet auch auf System:

> Live-Demo (Theme Toggle): Wähl Light, Dark oder System: Der Thumb gleitet, die Seite wechselt mit einem Kreis, der sich vom gedrückten Segment aus öffnet, und System folgt deinem Gerät. Probier sie unter [Theme Toggle](https://wingo-ui.com/components/theme-toggle) aus und installier sie mit `npx wingo-ui@latest add theme-toggle`.

```tsx
// components/site-header.tsx
import { ThemeToggle } from "@/components/ui/theme-toggle";

export function SiteHeader() {
  return (
    <header className="flex h-14 items-center justify-between border-b border-border px-4">
      <span className="font-semibold">Northwind CRM</span>
      <ThemeToggle shortcut="shift+t" />
    </header>
  );
}
```

Installier ihn mit `npx wingo-ui@latest add theme-toggle`. Er liest `useTheme()` aus dem Provider von oben (oder nimmt `value` und `onValueChange`, wenn du das Theme selbst speicherst), und sein Icon vor der Hydration nutzt `dark:`-Klassen. Der Provider muss also die Klasse schreiben, die deine Variante erwartet. Mit `variant="segmented"` und `includeSystem` wird er zur Zeile Light / Dark / System auf einer Einstellungsseite. Theme Toggle gehört zu Pro (siehe [Preise](https://wingo-ui.com/de/pricing)). Willst du einen kostenlosen, nutzt der Button mit zwei Icons im Guide zur [Next.js Component Library](https://wingo-ui.com/de/blog/next-js-component-library-setup) denselben `dark:hidden`-Trick in etwa 20 Zeilen.

## Was solltest du als Nächstes tun?

Füg die `@custom-variant`-Zeile ein, setz `attribute="class"` auf den Provider und durchsuch deine Komponenten mit grep nach `dark:`: Jede wiederholte Klasse ist ein Token, das auf seinen Namen wartet. Öffne dann die Seite mit blockiertem JavaScript in den DevTools und prüf, ob das Server-HTML schon das richtige Theme zeigt. Mehr Artikel zu diesem Stack findest du unter [Next.js und Tailwind CSS](https://wingo-ui.com/de/blog/category/nextjs-tailwind).

## Komponenten in diesem Artikel

- [Theme Toggle](https://wingo-ui.com/components/theme-toggle) (Pro): A React theme toggle for light, dark and system mode on next-themes: an animated sun and moon button, a segmented control or a menu. Installation: `npx wingo-ui@latest add theme-toggle`
- [Tones](https://wingo-ui.com/components/tones) (Free): One way to color a React component: a tone or any CSS color becomes five CSS variables every variant paints with. Installation: `npx wingo-ui@latest add tones`
- [Card](https://wingo-ui.com/components/card) (Free): A React card component for listings, stats, settings and plans: media, header, body and footer parts, and a lift-and-press mode. Installation: `npx wingo-ui@latest add card`

## Häufige Fragen

### Warum funktioniert Tailwind v4 Dark Mode nicht mit meinem Toggle?

Ohne eine @custom-variant-dark-Zeile kompiliert Tailwind v4 dark: zu @media (prefers-color-scheme: dark), folgt also dem Betriebssystem und ignoriert jede Klasse, die du setzt. Füg @custom-variant dark (&:where(.dark, .dark *)); nach @import "tailwindcss" ein und prüf, ob dein Toggle genau die Klasse oder das Attribut schreibt, nach dem die Variante sucht.

### Funktioniert darkMode: "class" in tailwind.config.js noch mit Tailwind v4?

Nur wenn das Stylesheet die Datei per @config lädt, denn Tailwind v4 liest tailwind.config.js nicht mehr von selbst. Eine @custom-variant-Zeile im CSS erledigt dasselbe und ist der vorgesehene Weg in v4.

### Was ist der Unterschied zwischen :is(.dark *) und :where(.dark, .dark *)?

Die :where-Form trifft auch das Element, das die dark-Klasse trägt, und fügt keine Spezifität hinzu, während :is(.dark *) nur dessen Nachfahren trifft und die Spezifität einer Klasse hinzufügt. Liegt die Klasse auf html und deine Styles auf body oder darunter, verhalten sich beide gleich.

### Soll next-themes mit Tailwind v4 attribute class oder data-theme nutzen?

Beides geht, solange die Variante dazu passt. next-themes nutzt standardmäßig data-theme, also übergib entweder attribute="class" an den ThemeProvider oder schreib die Variante als @custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));.

### Brauche ich dark:-Klassen auf jeder Komponente?

Nein. Leg Farben in CSS-Variablen, definier sie in einem .dark-Block neu und bilde sie mit @theme inline auf Utilities ab; bg-surface oder text-muted-foreground wechseln dann mit dem Theme. Heb dir dark: für echte Ausnahmen auf, etwa ein Logo oder einen Screenshot.

---

Quelle: https://wingo-ui.com/de/blog/tailwind-v4-dark-mode
