# Tailwind v4 Design Tokens: Farben, Dark Mode, Kontrast

> Tailwind v4 Design Tokens aus einer produktiven React-Bibliothek: @theme inline, semantische Flächen, Statusfarben, Dark Mode und ein Kontrast-Check für CI.

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

## Kurz gesagt

Tailwind v4 Design Tokens sind CSS-Variablen: Werte, die sich zwischen Light und Dark ändern, stehen in :root und .dark und werden mit @theme inline auf Utilities gemappt, sodass bg-surface oder text-muted-foreground ohne dark:-Präfix dem Theme folgen. Ein vollständiges Set benennt Rollen statt Farben: Flächen in der Reihenfolge, in der sie sich stapeln, Textstufen, Tones mit getrennter Füllung und lesbarer Textfarbe, dazu Skalen für Radius, Elevation, z-index und Motion. Prüf jedes Paar aus Text und Fläche in beiden Themes per Skript auf 4.5:1, denn eine Farbe, die auf Weiß besteht, scheitert oft auf einem dunklen Popover.

Tailwind CSS v4 hat die Konfiguration aus der `tailwind.config.js` ins CSS verlegt, und viele Guides hören beim ersten Schritt auf: ein paar Markenfarben in `@theme` packen und das Ganze Tokens nennen. Eine Komponentenbibliothek braucht mehr als eine Palette. Sie braucht Tailwind v4 Design Tokens für jede Entscheidung, die eine Komponente trifft: welche Fläche vor welcher liegt, wie blass sekundärer Text werden darf, was eine Warnung als Füllung und was sie als Text nutzt, wie rund ein 32px-Button neben einem 48px-Button ist, wie ein Popover auf einer schwarzen Seite einen Schatten wirft und auf welcher Ebene sich ein Select innerhalb eines Dialogs öffnet. Dieser Guide zeigt das Token-System, das wir in Wingo UI nutzen, einer Komponentenbibliothek für React 19 und Tailwind CSS v4, mit dem echten CSS und den Kontrastwerten hinter jeder Entscheidung. Wir bauen Wingo UI, sind also nicht neutral; Fakten von außen sind mit ihrer Quelle verlinkt und datiert.

## Was sind Design Tokens in Tailwind v4?

Design Tokens sind in Tailwind v4 CSS Custom Properties. Die Tailwind v4 Theme-Variablen, die du in einem `@theme`-Block deklarierst, haben zwei Aufgaben zugleich: Sie sind CSS-Variablen, die du mit `var()` lesen kannst, und ihr Namespace entscheidet, welche Utilities Tailwind erzeugt. `--color-surface` erzeugt `bg-surface`, `text-surface` und `border-surface`; `--shadow-raised` erzeugt `shadow-raised`. Tailwind CSS v4.0 hat diese CSS-first-Konfiguration am 22. Januar 2025 ausgeliefert ([Release-Post](https://tailwindcss.com/blog/tailwindcss-v4)), und die [Doku zu Theme-Variablen](https://tailwindcss.com/docs/theme) listet die Namespaces. Wir nutzen Stand Oktober 2026 Tailwind 4.3.3.

Die Namespaces, die eine Komponentenbibliothek am häufigsten nutzt:

| Namespace | Utilities, die er erzeugt | Was wir dort ablegen |
| --- | --- | --- |
| `--color-*` | `bg-*`, `text-*`, `border-*`, `ring-*`, `fill-*` | Flächen, Textstufen, Tones, Rahmen, den Fokusring |
| `--shadow-*` | `shadow-*` | drei Elevation-Stufen |
| `--radius-*` | `rounded-*` | eine Legacy-Skala, die neuer Code meidet (siehe Abschnitt zum Radius) |
| `--z-index-*` | `z-*` | sieben Stapelebenen |
| `--ease-*`, `--transition-duration-*` | `ease-*`, `duration-*` | Motion-Kurven und Dauern |
| `--font-*` | `font-*` | Schriftfamilien |
| `--spacing` | `p-*`, `m-*`, `gap-*`, `w-*`, `h-*` | das 4px-Raster (`0.25rem`) |

`--z-index-*` und `--transition-duration-*` stehen Stand Oktober 2026 nicht in der Tabelle der Doku, Tailwind 4.3 kompiliert sie aber: Unser CSS enthält `.z-modal { z-index: var(--z-modal) }`.

## Wie solltest du Tailwind v4 Design Tokens in Schichten aufbauen?

Nutz drei Schichten. Rohwerte, die sich zwischen den Themes ändern, stehen in `:root` und `.dark`. Ein Mapping in `@theme inline` macht daraus Utilities. Komponenten beziehen ihre Farben dann aus ein paar Laufzeit-Variablen, die ein Prop setzt, sodass ein `tone` oder eine eigene `color` nie eine eigene Klasse braucht.

Hier ein gekürzter, aber lauffähiger Auszug aus unserer `app/globals.css`, mit den echten Werten:

```css
@import "tailwindcss";

@custom-variant dark (&:is(.dark *));

:root {
  --background: oklch(1 0 0);
  --foreground: oklch(0.3211 0 0);
  --surface: #ffffff;
  --popover: oklch(1 0 0);
  --muted-foreground: oklch(0.52 0.0234 264.3637);
  --success: #15803d;
  --success-foreground: #ffffff;
  --success-soft: #e7f6ec;
  --success-soft-foreground: #166534;
  --elevation-raised: 0 0 0 1px rgb(0 0 0 / 0.07), 0 1px 2px rgb(0 0 0 / 0.05), 0 2px 8px -2px rgb(0 0 0 / 0.06);
}

.dark {
  --background: oklch(0 0 0);
  --foreground: oklch(0.9219 0 0);
  --surface: #1a1a1a;
  --popover: #262626;
  --muted-foreground: oklch(0.7155 0 0);
  --success-soft: #0d2315;
  --success-soft-foreground: #4ade80;
  --elevation-raised: inset 0 1px 0 0 rgb(255 255 255 / 0.06), 0 0 0 1px rgb(255 255 255 / 0.08), 0 1px 2px rgb(0 0 0 / 0.5);
}

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

Die Success-Füllung und ihr weißer Text sind in beiden Themes gleich (5.02:1), deshalb deklariert dieser Auszug sie nur in `:root`, und `.dark` erbt sie. Alles, was sich ändert, wird unter `.dark` neu deklariert, und eine Komponente nutzt überall dieselben Klassen:

```tsx
export function InvoiceRow() {
  return (
    <div className="flex items-center justify-between rounded-2xl bg-surface p-4 text-foreground shadow-raised">
      <div>
        <p className="text-[15px] font-medium">Rechnung 1042</p>
        <p className="text-sm text-muted-foreground">Emma Carter, New York</p>
      </div>
      <span className="rounded-full bg-success-soft px-2 py-0.5 text-xs font-medium text-success-soft-foreground">Bezahlt</span>
    </div>
  );
}
```

Die Rohnamen folgen bewusst der Konvention von shadcn/ui: `--background`, `--card`, `--popover` und `--muted-foreground` sind shadcn-Namen, und die, die shadcn nicht hat (`--surface`, `--canvas`, die `--success-*`-Familie), folgen demselben Muster aus `name` und `name-foreground`. Ein Projekt, das schon shadcn-Tokens hat, kann unsere übernehmen, ohne etwas umzubenennen.

## Wann brauchst du Tailwind v4 @theme inline?

Nimm `@theme inline` immer dann, wenn eine Theme-Variable auf eine andere Variable zeigt, und das gilt für jedes Token, das sich mit dem Theme ändert. Die [Tailwind-Doku](https://tailwindcss.com/docs/theme) sagt es direkt: Mit `inline` nutzt die Utility-Klasse den Wert der Theme-Variable, statt auf die Theme-Variable selbst zu verweisen. So sieht das im kompilierten CSS aus:

```css
/* @theme inline { --color-surface: var(--surface); } */
.bg-surface {
  background-color: var(--surface);
}

/* @theme { --color-surface: var(--surface); } */
:root, :host {
  --color-surface: var(--surface);
}
.bg-surface {
  background-color: var(--color-surface);
}
```

Der Unterschied zeigt sich unterhalb des Root-Elements. Eine Custom Property erbt ihren berechneten Wert, und `var()` wird dort eingesetzt, wo die Property deklariert ist. In der zweiten Version wird `--color-surface` einmal aufgelöst, auf `:root`, und jedes Element erbt dieses Ergebnis. Eine `.dark`-Klasse auf `<html>` funktioniert trotzdem, denn `<html>` ist das Root-Element. Eine dunkle Section in einer hellen Seite (`<section className="dark">`), ein dunkler Vorschaubereich oder ein `data-theme`-Attribut auf einem Container funktionieren nicht: Die Section erbt den hellen Wert, den `:root` schon aufgelöst hat. Mit `inline` liest `bg-surface` `var(--surface)` auf dem Element selbst und übernimmt so den nächstgelegenen Override. Die Tailwind-Doku erklärt die Option mit einem ganz ähnlichen Beispiel aus Eltern- und Kindelement.

Einen Haken hat `inline` noch: Wenn du selbst `var(--color-surface)` schreibst, in einem Arbitrary Value wie `bg-[var(--color-surface)]` oder in normalem CSS, gibt Tailwind 4.3 dafür `--color-surface: var(--surface)` auf `:root` aus, und diese Referenz wird wieder auf dem Root-Element aufgelöst. Lies in Komponenten deshalb die Rohvariable: `bg-(--surface)` oder `var(--surface)`.

Zwei weitere Regeln aus der Doku: Theme-Variablen müssen auf oberster Ebene stehen, nie unter einem Selektor oder einer Media Query, also landen Werte pro Theme ohnehin außerhalb von `@theme`; und Tailwind gibt nur die Theme-Variablen aus, die dein Code nutzt, außer du schreibst `@theme static`. Die Rohvariablen in `:root` werden immer ausgeliefert, was hilft, wenn eine Chart-Bibliothek ein Token mit `getComputedStyle` liest.

## Welche semantischen Farb-Tokens braucht eine Komponentenbibliothek?

Benenn Farben nach ihrer Rolle, nie nach dem Farbton. Eine Komponentenbibliothek braucht Flächen in der Reihenfolge, in der sie sich stapeln, Textstufen mit einer Kontrast-Untergrenze, transparente Tints für Hover und Füllungen, einen Rahmen und einen Fokusring sowie Status-Tones. Semantische Farb-Tokens sorgen dafür, dass Komponenten nie `bg-gray-100` oder `text-blue-600` enthalten, und ein Theme kann jeden Wert ändern, ohne eine Komponente anzufassen.

### Flächen, von hinten nach vorn

| Token | Light | Dark | Einsatz |
| --- | --- | --- | --- |
| `background` | `#FFFFFF` | `#000000` | die Seite |
| `canvas` | `#F6F7F9` | `#0E0E10` | die Seite einer App (Dashboards, Admin), unter weißen Panels |
| `card` | `#EBEBEB` | `#121212` | flache graue Kacheln ohne Schatten |
| `surface` | `#FFFFFF` mit `shadow-raised` | `#1A1A1A` | erhöhte Panels: Listengruppen, Stat-Cards, Formulare |
| `popover` | `#FFFFFF` mit `shadow-floating` | `#262626` | alles, was schwebt: Menüs, Selects, Dialoge, Sheets, Toasts |
| `primary` | `#000000` | `#FAFAFA` | die neutrale volle Füllung und Tooltips |

Im Dark Mode werden die Flächen heller, je weiter sie nach vorn kommen: `#000`, `#121212`, `#1A1A1A`, `#262626`. Im Light Mode sind die meisten weiß, also übernimmt der Schatten die Trennung. `canvas` gibt es, weil ein Dashboard auf einer weißen Seite nichts hat, wovon sich weiße Cards abheben könnten; eine kühle graue Seite sorgt dafür, dass jedes `bg-surface`-Panel als Card wirkt.

### Textstufen

| Stufe | Klasse | Einsatz | Niedrigstes Verhältnis in unseren Token-Paaren |
| --- | --- | --- | --- |
| Primär | `text-foreground` | Titel, Werte, Labels | 10.60:1 (helle Card) |
| Sekundär | `text-muted-foreground` | Beschreibungen, Hilfetext, Zeitstempel | 4.62:1 (helle Card) |
| Deaktiviert | `text-foreground/45` | nur deaktivierte Labels | keine Vorgabe |
| Tone-Text | `text-success-soft-foreground` und verwandte Tokens | Fehler, Deltas, Links | 5.28:1 (Brand auf der hellen Card) |

`text-muted-foreground` ist die Untergrenze für alles, was Menschen lesen müssen. Die deaktivierte Stufe hat kein Kontrastziel, weil [WCAG 2.2 Erfolgskriterium 1.4.3](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html) Text in inaktiven Controls ausnimmt, und deshalb wird sie nie für etwas anderes genutzt.

### Tints statt grauer Füllungen

Hover-States, Feldfüllungen, Chips, Tracks und Skeletons nutzen transparenten Foreground: `bg-foreground/5`, `/7`, `/10` und `/15`, dazu `border-foreground/10` für Haarlinien. Tailwind 4.3 kompiliert den Opacity-Modifier zu `color-mix()`, mit der reinen Farbe als Fallback:

```css
.bg-foreground\/5 {
  background-color: var(--foreground);
  @supports (color: color-mix(in lab, red, red)) {
    background-color: color-mix(in oklab, var(--foreground) 5%, transparent);
  }
}
```

Ein Tint bezieht sich auf das, was hinter ihm liegt, also rückt er auf jeder Fläche und in beiden Themes eine Stufe in Richtung Textfarbe. Eine feste Füllung kann das nicht. Unser dunkles `--muted` ist `#171717`: heller als die schwarze Seite und dunkler als die `#1A1A1A`-Surface, dasselbe Feld würde also auf der einen erhaben und auf der anderen vertieft wirken. Der Fallback in voller Stärke erscheint nur in Browsern ohne `color-mix()`, und die liegen alle unter Tailwinds [Browser-Baseline](https://tailwindcss.com/docs/compatibility) von Chrome 111, Safari 16.4 und Firefox 128.

`secondary` und `accent` von shadcn/ui bleiben in unserer Datei, damit portierte Komponenten weiter funktionieren, aber unser `--accent` ist ein oranger Tint, und neue Komponenten nutzen keins von beiden.

## Wie funktionieren Statusfarben ohne eine Klasse pro Farbe?

Gib jedem Status-Tone vier Tokens: eine volle Füllung, den Text auf dieser Füllung, eine weiche getönte Fläche und Tone-Text, der auf der weichen Fläche und auf jeder neutralen Fläche lesbar ist. Dann lass Komponenten ihre Farben aus fünf generischen Variablen beziehen, die eine einzige Klasse setzt. Das ist die ganze Idee hinter dem kostenlosen [Tones-Utility](https://wingo-ui.com/components/tones).

Die vier Tokens pro Tone, für Brand, Success, Warning, Destructive und Info:

```css
--success: #15803d;                 /* bg-success: Buttons, volle Badges, Fortschritt */
--success-foreground: #ffffff;      /* Text auf der Füllung */
--success-soft: #e7f6ec;            /* bg-success-soft: weiche Badges, Alerts, ausgewählte Zeilen */
--success-soft-foreground: #166534; /* farbiger Text auf dem Tint und auf jeder neutralen Fläche */
```

Die Trennung zwischen Füllung und Tone-Text ist die Regel, die man am leichtesten überspringt, und genau dort bricht der Dark Mode. Füllungen sind darauf abgestimmt, weißen Text zu tragen, und als Text taugen sie wenig. `text-destructive` (`#DC2626`) auf unserem dunklen Popover kommt auf 3.13:1; `text-destructive-soft-foreground` (`#FF8A80`) auf derselben Fläche auf 6.63:1. Die Warning-Füllung ist Amber (`#F5A524`) mit 2.04:1 gegen Weiß, also trägt sie dunklen Text (`#2B1700`, 8.40:1) und wird nie als Text auf einer neutralen Fläche genutzt.

### Komponenten-Variablen

Eine Komponente mit einem `tone`-Prop mappt Tokens nicht selbst. `toneClasses[tone]` setzt fünf Variablen, und jede Variante färbt sich damit ein:

```ts
// lib/tones.ts (Auszug)
success:
  "[--tone:var(--success)] [--tone-foreground:var(--success-foreground)] [--tone-soft:var(--success-soft)] [--tone-soft-foreground:var(--success-soft-foreground)] [--tone-border:color-mix(in_oklab,var(--success)_45%,transparent)]",
```

Der neutrale Tone mappt `--tone` auf `--primary` (schwarz im Light, fast weiß im Dark) und baut seine weiche Fläche aus `color-mix(in oklab, var(--foreground) 7%, transparent)`, deshalb funktioniert ein neutraler Soft-Button auf jeder Fläche. Ein Badge nach diesem Muster:

```tsx
import type { ComponentProps } from "react";
import { colorVars, toneClasses, type Tone } from "@/lib/tones";
import { cn } from "@/lib/utils";

type StatusBadgeProps = ComponentProps<"span"> & { tone?: Tone; color?: string };

export function StatusBadge({ tone = "neutral", color, className, style, ...props }: StatusBadgeProps) {
  return (
    <span
      data-slot="status-badge"
      className={cn(
        toneClasses[tone],
        "inline-flex rounded-full bg-(--tone-soft) px-2 py-0.5 text-xs font-medium text-(--tone-soft-foreground)",
        className,
      )}
      style={{ ...colorVars(color), ...style }}
      {...props}
    />
  );
}
```

`bg-(--tone-soft)` ist die Kurzschreibweise von Tailwind v4 für `bg-[var(--tone-soft)]`. Das kostenlose [Badge](https://wingo-ui.com/components/badge) ist die vollständige Version dieses Musters, mit den Varianten Solid, Soft und Outline. `colorVars(color)` baut dieselben fünf Variablen aus einer beliebigen CSS-Farbe als Inline-Style. Der Wert für Text auf der Füllung nutzt Relative Color Syntax, `oklch(from <color> clamp(0, (0.7 - l) * 1000, 1) 0 0)`: weiß, wenn die OKLCH-Helligkeit der Farbe unter 0.7 liegt, schwarz darüber. Stand Oktober 2026 listet [caniuse](https://caniuse.com/css-relative-colors) volle Unterstützung für Relative Color Syntax ab Chrome 131, Safari 18 und Firefox 133 und davor nur teilweise Unterstützung; volle Unterstützung kam also später als Tailwinds eigene Baseline. Wo ein Browser den Wert nicht berechnen kann, ist die Deklaration zum Zeitpunkt der Wertberechnung ungültig, und der Text erbt die Farbe seines Elternelements. Wenn du diese Browser unterstützt, erzwing die Textfarbe mit `className="[--tone-foreground:white]"`.

> Live-Demo (Tones): Jede Zeile zeigt einen Tone auf drei Arten, gebaut aus denselben fünf Variablen, und die letzte Zeile erzeugt colorVars aus einem einzigen Hex-Wert, #0EA5E9. Wechsel das Theme der Seite: Die vollen Füllungen bleiben, die weichen Tints und der Tone-Text nehmen ihre dunklen Werte an. Probier sie unter [Tones](https://wingo-ui.com/components/tones) aus und installier sie mit `npx wingo-ui@latest add tones`.

Der kostenlose [Button](https://wingo-ui.com/components/button) ist das Referenzbeispiel für die Nutzung. Seine Varianten-Map enthält überhaupt keine Farbe:

```ts
const VARIANTS: Record<ButtonVariant, string> = {
  solid: "bg-(--tone) text-(--tone-foreground) shadow-raised",
  soft: "bg-(--tone-soft) text-(--tone-soft-foreground)",
  outline: "border border-(--tone-border) text-(--tone-soft-foreground)",
  ghost: "text-(--tone-soft-foreground)",
  link: "rounded-[4px] text-(--tone-soft-foreground) underline decoration-current/35 decoration-1 underline-offset-4 transition-[color,text-decoration-color] hover:decoration-current",
};
```

Sechs Tones mal fünf Varianten, plus jede eigene Farbe, aus fünf Zeilen. Hover und Press sind eine Schicht aus `currentColor` mit 8% und 12% Deckkraft, also funktionieren auch sie für jeden Tone ohne zusätzliche Klassen.

## Wie gestaltest du Dark Mode mit Tokens?

Behandle Dark als zweiten Satz Werte für dieselben Rollen, von Hand gewählt, und schalte ihn mit einer Klasse um. Komponenten behalten in beiden Themes dieselben Klassen, also braucht fast nichts in einer Komponente ein `dark:`-Präfix.

Die Variante: Unsere Tokens nutzen, wie die von shadcn/ui, `@custom-variant dark (&:is(.dark *));`. Die [Tailwind-Doku zum Dark Mode](https://tailwindcss.com/docs/dark-mode) zeigt `&:where(.dark, .dark *)`, das auch das Element selbst trifft, das die Klasse trägt, und keine Spezifität hinzufügt. Mit der Klasse auf `<html>` verhalten sich beide im normalen Einsatz gleich. Die Klasse selbst kommt von [next-themes](https://github.com/pacocoursey/next-themes), das sie vor dem ersten Paint schreibt: Pack die App in seinen `ThemeProvider` mit `attribute="class"` (die vollständige Provider-Datei steht weiter unten im Abschnitt zu Props) und rendere im Root-Layout `<html lang="en" suppressHydrationWarning>`, denn next-themes ändert die Klasse, bevor React hydriert:

```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>
  );
}
```

Zwei Posts vertiefen das Umschalten selbst: [Tailwind v4 Dark Mode](https://wingo-ui.com/de/blog/tailwind-v4-dark-mode) behandelt die `@custom-variant`-Zeile, den Toggle und warum `dark:` sie ignorieren kann, und der [Fix für den next-themes Hydration Error](https://wingo-ui.com/de/blog/next-themes-hydration-error) entfernt das Aufblitzen des Themes und rendert einen Toggle, der schon beim ersten Paint stimmt.

Wie ein gestaltetes Dark Theme in der Praxis aussieht, aus unserer Token-Datei:

- **Flächen werden heller, je weiter sie nach vorn kommen.** `#000` Seite, `#121212` Card, `#1A1A1A` Surface, `#262626` Popover.
- **Füllungen bleiben, weiche Flächen wechseln.** Die Brand-Füllung ist in beiden Themes `#D63F00`, weißer Text behält also 4.59:1. Ihre weiche Fläche wechselt von `#FFEFE6` zu `#2A1509` und ihr Text von `#B03400` zu `#FF8A4C`.
- **Die neutrale Füllung kehrt sich um.** `primary` ist im Light Mode schwarz mit weißem Text und im Dark Mode `#FAFAFA` mit `#171717`, also behält der neutrale volle Button 21:1 im Light und 17.16:1 im Dark.
- **Elevation wechselt die Technik.** Ein Schlagschatten ist auf Schwarz kaum zu sehen, also setzen dunkle Schatten auf eine beleuchtete Oberkante und eine weiße Haarlinie (siehe die Elevation-Stufen unten).
- **Der Fokusring verschiebt sich.** `--ring` ist `oklch(0.60 0.20 36.93)` im Light und `oklch(0.6617 0.2215 36.93)` im Dark, auf Weiß also dunkler, damit eine 2px-Outline auf Weiß und auf der grauen Card 3:1 hält.
- **Manche Komponenten brauchen eine Ausnahme.** Unsere weiche [Card](https://wingo-ui.com/components/card) nutzt im Light `bg-card`, im Dark aber `bg-foreground/7`, weil die dunkle `--card` dasselbe Grau hat wie eine dunkle Sidebar oder Stage und die Card dort verschwinden würde. Ein Skript, das Farben invertiert, trifft diese Entscheidung nie.

## Wie prüfst du den Kontrast für jedes Token-Paar?

Liste die Paare auf, die zusammen genutzt werden sollen, rechne beide Farben nach sRGB um, berechne das WCAG-Verhältnis in beiden Themes und lass den Build unter 4.5:1 scheitern. WCAG verlangt 4.5:1 für Fließtext und 3:1 für großen Text ([1.4.3](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html)) sowie 3:1 für UI-Komponenten und Fokusindikatoren gegenüber dem, was daneben liegt ([1.4.11](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html)).

Unser Check deckt 54 Paare pro Theme ab, insgesamt 108, und meldet Stand Release 1.7.0 kein Paar unter 4.5:1. Die knappsten Paare sind `brand-foreground` auf `brand` mit 4.59:1 und `muted-foreground` auf der hellen `card` mit 4.62:1. Hier ist eine eigenständige Version, die du in `scripts/` legen und mit `npx tsx scripts/check-contrast.ts` ausführen kannst. Sie liest Hex- und `oklch()`-Werte (Helligkeit als Dezimalzahl oder Prozent), folgt `var()`-Referenzen und beendet sich bei einem Fehlschlag mit Code 1. Sie erwartet die Token-Namen aus diesem Guide; wenn deine anders heißen, pass `SURFACES`, `TONES` und `PAIRS` an:

```ts
// scripts/check-contrast.ts
import { readFileSync } from "node:fs";

type Rgb = [number, number, number];
const css = readFileSync("app/globals.css", "utf8").replace(/\/\*[\s\S]*?\*\//g, "");

// jedes --name: value in den Blöcken, die mit diesem Selektor beginnen
function vars(selector: string) {
  const map = new Map<string, string>();
  const block = new RegExp(`(?:^|\\n)${selector.replace(".", "\\.")}\\s*\\{([^}]*)\\}`, "g");
  for (const m of css.matchAll(block))
    for (const d of m[1].matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)) map.set(d[1], d[2].trim());
  return map;
}

export function oklchToRgb(l: number, c: number, h: number): Rgb {
  const a = c * Math.cos((h * Math.PI) / 180);
  const b = c * Math.sin((h * Math.PI) / 180);
  const l3 = (l + 0.3963377774 * a + 0.2158037573 * b) ** 3;
  const m3 = (l - 0.1055613458 * a - 0.0638541728 * b) ** 3;
  const s3 = (l - 0.0894841775 * a - 1.291485548 * b) ** 3;
  const linear = [
    4.0767416621 * l3 - 3.3077115913 * m3 + 0.2309699292 * s3,
    -1.2684380046 * l3 + 2.6097574011 * m3 - 0.3413193965 * s3,
    -0.0041960863 * l3 - 0.7034186147 * m3 + 1.707614701 * s3,
  ];
  // in sRGB begrenzen, dann gamma-kodieren
  return linear.map((v) => {
    const x = Math.min(1, Math.max(0, v));
    return x <= 0.0031308 ? 12.92 * x : 1.055 * x ** (1 / 2.4) - 0.055;
  }) as Rgb;
}

function parse(value: string, map: Map<string, string>): Rgb {
  const ref = value.match(/^var\((--[\w-]+)\)$/);
  if (ref) return parse(map.get(ref[1]) ?? "", map);
  if (value.startsWith("#")) {
    const hex = value.length === 4 ? [...value.slice(1)].map((ch) => ch + ch).join("") : value.slice(1);
    return [0, 2, 4].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255) as Rgb;
  }
  const ok = value.match(/^oklch\(\s*([\d.]+)(%?)\s+([\d.]+)\s+([\d.]+)\s*\)$/);
  if (ok) return oklchToRgb(ok[2] ? +ok[1] / 100 : +ok[1], +ok[3], +ok[4]);
  throw new Error(`"${value}" lässt sich nicht parsen`);
}

const channel = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
const luminance = ([r, g, b]: Rgb) => 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);

export function contrast(fg: Rgb, bg: Rgb) {
  const [hi, lo] = [luminance(fg), luminance(bg)].sort((x, y) => y - x);
  return (hi + 0.05) / (lo + 0.05);
}

const SURFACES = ["background", "card", "popover", "surface", "canvas"];
const TONES = ["brand", "success", "warning", "info", "destructive"];
const PAIRS = [
  ["foreground", "background"],
  ...SURFACES.map((s) => ["muted-foreground", s]),
  ...TONES.flatMap((t) => [
    [`${t}-foreground`, t],
    [`${t}-soft-foreground`, `${t}-soft`],
    ...SURFACES.map((s) => [`${t}-soft-foreground`, s]),
  ]),
];

const light = vars(":root");
const themes = { light, dark: new Map([...light, ...vars(".dark")]) };
let failures = 0;
for (const [theme, map] of Object.entries(themes)) {
  for (const [fg, bg] of PAIRS) {
    const ratio = contrast(parse(map.get(`--${fg}`) ?? "", map), parse(map.get(`--${bg}`) ?? "", map));
    if (ratio < 4.5) {
      failures++;
      console.log(`FEHLER ${theme}: ${fg} auf ${bg} hat ${ratio.toFixed(2)}:1`);
    }
  }
}
console.log(`${PAIRS.length * 2} Paare geprüft, ${failures} unter 4.5:1`);
process.exitCode = failures ? 1 : 0;
```

Diese gekürzte Version prüft 82 Paare. Unser vollständiges Skript ergänzt die übrigen Paare für Flächen, Primary und Legacy-Tokens und verrechnet transparente Werte vorher mit der Seite. Der wertvolle Teil ist die Paarliste: Sie hält fest, welcher Text auf welcher Fläche stehen darf, also wird ein neues Token an dem Tag geprüft, an dem es dazukommt. Lass es in CI neben dem Type-Check laufen.

## Solltest du Tailwind v4 OKLCH-Werte oder Hex schreiben?

Nimm das Format, mit dem du am besten arbeiten kannst; das Format ändert den Kontrast nicht. Tailwinds eigene Palette ist in v4.0 von `rgb` auf `oklch` umgestiegen, um den größeren Farbraum zu nutzen ([Release-Post](https://tailwindcss.com/blog/tailwindcss-v4)), und die [Farbreferenz](https://tailwindcss.com/docs/colors) listet jeden Tailwind v4 OKLCH-Wert, etwa `--color-red-500: oklch(63.7% 0.237 25.331)`. OKLCH verdient seinen Platz, wenn du Farben im Code ableitest, denn die Helligkeit ist ein eigener Kanal: Du kannst sie verschieben, während Hue und Chroma bleiben, und das Ergebnis sieht immer noch nach derselben Farbe aus.

In unserer Datei sind Neutraltöne `oklch()` mit Chroma null (`oklch(0.3211 0 0)` ist der helle Foreground), und die Tone-Werte sind Hex, von Hand gegen den Kontrast-Check abgestimmt. Der [Theme-Generator](https://wingo-ui.com/theme) geht den umgekehrten Weg: Du wählst für jeden Tone eine Grundfarbe (Brand, Success, Warning, Danger, Info), und er leitet daraus Füllung, weiche Fläche und Text des Tones in OKLCH ab. Für den Light Mode setzt er die weiche Fläche auf Helligkeit 0.965 mit Chroma höchstens 0.035 und senkt dann die Helligkeit des Tone-Texts ab 0.6 in Schritten von 0.005, bis er 4.6:1 auf der weichen Fläche und auf jeder neutralen Fläche erreicht. Verschieb `oklchToRgb` und `contrast` aus dem Kontrast-Skript in ein eigenes Modul, `scripts/color.ts`, damit der Import den Check nicht startet, und dieselbe Suche passt in ein paar Zeilen:

```ts
import { contrast, oklchToRgb } from "./color";

type Rgb = [number, number, number];

// Hue und Chroma behalten, Helligkeit verschieben, bis die Farbe auf jeder möglichen Fläche lesbar ist
export function findReadable(c: number, h: number, surfaces: Rgb[], from: number, to: number, target = 4.6): Rgb {
  const step = from < to ? 0.005 : -0.005;
  for (let l = from; step > 0 ? l <= to : l >= to; l += step) {
    const rgb = oklchToRgb(l, c, h);
    if (Math.min(...surfaces.map((bg) => contrast(rgb, bg))) >= target) return rgb;
  }
  return oklchToRgb(to, c, h);
}
```

Das Ziel ist 4.6 statt 4.5, damit das Runden auf Hex eine Farbe nicht unter die Grenze drücken kann. Eine Warnung noch: Ein OKLCH-Wert mit hoher Chroma kann außerhalb von sRGB liegen, und was ein Bildschirm dann zeigt, hängt von Display und Browser ab. Halte Token-Werte innerhalb von sRGB oder miss zumindest den begrenzten sRGB-Wert, so wie `oklchToRgb` es tut.

## Wie werden Radius, Elevation, z-index und Motion zu Tokens?

Sie werden zu Skalen mit einer festen Zahl von Stufen, jede mit genau einer Aufgabe, sodass eine Komponente eine Stufe wählt statt einer Zahl. Ein Tailwind v4 Design System, das nur Farben tokenisiert, landet trotzdem bei Schatten-Einzelfällen und irgendwo bei einem `z-[9999]`.

### Der Radius folgt der Größe

Koppel den Radius an die Größe des Controls. Ein 32px- und ein 48px-Button wirken nur dann wie dieselbe Form, wenn der größere den größeren Radius hat. Unsere Controls übernehmen diese Map vom Button:

```ts
type Scale = "sm" | "md" | "lg";
type ButtonRadius = "none" | "sm" | "md" | "lg" | "full";

const RADII: Record<ButtonRadius, Record<Scale, string>> = {
  none: { sm: "rounded-none", md: "rounded-none", lg: "rounded-none" },
  sm: { sm: "rounded-[6px]", md: "rounded-[8px]", lg: "rounded-[10px]" },
  md: { sm: "rounded-[8px]", md: "rounded-[10px]", lg: "rounded-[12px]" },
  lg: { sm: "rounded-[10px]", md: "rounded-[14px]", lg: "rounded-[16px]" },
  full: { sm: "rounded-full", md: "rounded-full", lg: "rounded-full" },
};
```

Flächen nutzen `rounded-2xl` (16px) für Cards, Menüs und Popovers, `rounded-3xl` (24px) für Dialoge und `rounded-t-[28px]` für Bottom Sheets. Alles innerhalb einer Fläche mit Padding bekommt den äußeren Radius minus Padding: Ein Menü mit `rounded-2xl p-1.5` hat Items mit `rounded-[10px]`. Größen folgen derselben Logik: 32, 40 und 48px, wobei die 40px-Größe auf Touchscreens auf 44px wächst, die Regel aus unserem [Guide zu Mobile-first React-Komponenten](https://wingo-ui.com/de/blog/mobile-first-react-components).

Die Falle ist ein globales Radius-Token. Unsere Datei trägt für portierte Komponenten noch die ältere Skala von shadcn/ui: `--radius: 0.375rem`, `--radius-lg: var(--radius)` und `--radius-sm: calc(var(--radius) - 4px)`. Das biegt Tailwinds `rounded-sm`, `rounded-md`, `rounded-lg` und `rounded-xl` überall auf 2, 4, 6 und 10px um, auch in Markup, das du aus der Tailwind-Doku kopierst (das kompilierte `.rounded-lg` ist `border-radius: var(--radius)`), deshalb nutzen neue Komponenten diese vier Klassen nie. Stand Oktober 2026 leitet die [Theming-Doku von shadcn/ui](https://ui.shadcn.com/docs/theming) die Skala stattdessen per Multiplikation ab (`--radius-sm: calc(var(--radius) * 0.6)` bis `--radius-4xl`), was dieselbe projektweite Wirkung hat. Wenn du den Radius global themst, musst du jede Komponente kontrollieren, die ihn nutzt.

### Drei Elevation-Stufen

Drei Stufen reichen für eine Bibliothek: `shadow-raised` für Buttons, Cards und Thumbs, `shadow-floating` für Popovers, Menüs und Toasts, `shadow-modal` für Dialoge und Sheets. Jede Stufe bringt ihre eigene 1px-Haarlinie mit, eine erhöhte Fläche braucht also keinen Rahmen:

```css
:root {
  --elevation-floating: 0 0 0 1px rgb(0 0 0 / 0.06), 0 4px 12px -2px rgb(0 0 0 / 0.08), 0 16px 36px -10px rgb(0 0 0 / 0.16);
}
.dark {
  /* auf einer schwarzen Seite ist ein Schlagschatten kaum sichtbar: eine beleuchtete Oberkante und eine Haarlinie übernehmen */
  --elevation-floating: inset 0 1px 0 0 rgb(255 255 255 / 0.07), 0 0 0 1px rgb(255 255 255 / 0.09), 0 8px 24px -4px rgb(0 0 0 / 0.6), 0 20px 48px -12px rgb(0 0 0 / 0.7);
}
@theme inline {
  --shadow-floating: var(--elevation-floating);
}
```

Eine interaktive Card wechselt beim Hover zwischen zwei dieser Stufen, und das Anheben läuft als CSS-Transition auf einer Spring-Kurve (siehe unten), also animiert ein Raster aus fünfzig Cards ohne JavaScript.

> Live-Demo (Card): Fahr mit der Maus über die Card, um zu sehen, wie sie sich von shadow-raised auf shadow-floating hebt, und drück sie dann, um das Eindrücken auf 98% zu sehen (Smartphones bekommen nur das Drücken). Wechsel das Theme der Seite, um zu sehen, wie die dunkle Elevation stattdessen eine beleuchtete Oberkante zeichnet. Probier sie unter [Card](https://wingo-ui.com/components/card) aus und installier sie mit `npx wingo-ui@latest add card`.

### Stapelebenen

| Klasse | Wert | Einsatz |
| --- | --- | --- |
| `z-dropdown` | 1000 | Panels im Fluss, die nicht in ein Portal gerendert werden |
| `z-sticky` | 1100 | Sticky Header, Tab-Bars, CTA-Leisten |
| `z-overlay` | 1200 | der abgedunkelte Hintergrund |
| `z-modal` | 1300 | Dialoge, Sheets, Drawer |
| `z-popover` | 1400 | Menüs, Selects und Popovers im Portal |
| `z-toast` | 1500 | Toasts |
| `z-tooltip` | 1600 | Tooltips, immer ganz oben |

Popovers liegen absichtlich über Modals: Ein Select in einem Dialog muss sich darüber öffnen. Kein Overlay in der Bibliothek nutzt eine rohe Zahl wie `z-50`; kleine Werte wie `z-10` ordnen nur Teile innerhalb einer Komponente.

### Motion

CSS-Transitions nutzen drei Dauern (`duration-fast` 150ms, `duration-normal` 240ms, `duration-slow` 400ms) und benannte Kurven (`ease-smooth` ist `cubic-bezier(0.22, 1, 0.36, 1)`, `ease-sheet` ist `cubic-bezier(0.32, 0.72, 0, 1)`). Unsere JavaScript-Springs (die vier [Framer Motion Spring-Presets](https://wingo-ui.com/de/blog/framer-motion-spring-presets)) werden außerdem in CSS-`linear()`-Easings abgetastet: `ease-spring` mit `duration-spring` (500ms) entspricht einer Spring mit Stiffness 300 und Damping 30. Wenn das Betriebssystem reduzierte Bewegung anfordert, sollten diese Springs kurzen Fades weichen; unser [Guide zu prefers-reduced-motion in React](https://wingo-ui.com/de/blog/prefers-reduced-motion-react) setzt das in Next.js ohne Hydration Mismatch um.

## Können Komponenten-Props wie Design Tokens funktionieren?

Ja, für die Entscheidungen, die Props sind: der Radius jedes Buttons, die Standardgröße von Feldern, die Variante jeder Card. CSS-Variablen können kein Prop setzen, also liest Wingo UI diese Werte aus einem React-Provider, und `UIProvider defaults` wird zur Token-Schicht für Props. Der kostenlose [UIProvider](https://wingo-ui.com/components/ui-config) legt app-weite Defaults unter die Props, die jede Komponente bekommt, geschlüsselt nach ihrem `data-slot`-Namen:

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

import type { ReactNode } from "react";
import { ThemeProvider } from "next-themes";
import { UIProvider } from "@/lib/ui-config";

export function Providers({ children }: { children: ReactNode }) {
  return (
    <ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
      <UIProvider locale="en-US" defaults={{ button: { radius: "full" }, card: { variant: "outline" } }}>
        {children}
      </UIProvider>
    </ThemeProvider>
  );
}
```

In der Komponente liest die erste Zeile die Defaults, und die Defaults in der Signatur bleiben der letzte Fallback:

```tsx
export function Button(buttonProps: ButtonProps) {
  const { variant = "solid", tone = "neutral", size = "md", radius = "md", ...rest } = useDefaults("button", buttonProps);
  // ...
}
```

Explizite Props gewinnen, dann der Provider, dann die eigenen Defaults der Komponente. Ein `undefined`-Prop überschreibt keinen Provider-Wert, also können Wrapper Props durchreichen, ohne das Theme zu löschen. Dieselbe Signatur nimmt `ref` als normales Prop, ohne `forwardRef`-Wrapper; unser [Migrationsguide zu forwardRef in React 19](https://wingo-ui.com/de/blog/react-19-forwardref-ref-as-prop) bringt ältere Komponenten auf dieses Muster, TypeScript-Typen inklusive.

> Live-Demo (UIProvider): Der erste Button nutzt seine eingebauten Defaults; die beiden darunter bekommen radius full, size lg und tone brand von einem UIProvider, und der Outline-Button behält die Variante, die er selbst übergibt. Probier sie unter [UIProvider](https://wingo-ui.com/components/ui-config) aus und installier sie mit `npx wingo-ui@latest add ui-config`.

## Wie bringst du diese Tokens in ein bestehendes Next.js-Projekt?

Führ `npx wingo-ui@latest init` in einem Projekt mit Tailwind CSS v4 aus. Der Befehl schreibt `wingo-ui.json` und `lib/utils.ts` (den `cn`-Helper), installiert `clsx` und `tailwind-merge` und führt die Tokens in das Stylesheet zusammen, das `@import "tailwindcss"` enthält. Er ergänzt nur fehlende Namen und behält deine Werte; `add` bringt später alle Tokens mit, die eine neue Komponente braucht. Der komplette Block, 74 Variablen in `:root` plus ihre dunklen Werte und Tailwind-Mappings, liegt unter `/r/tokens.css`, und Coding-Agents bekommen dasselbe CSS über das MCP-Tool `get_theme_tokens` (siehe unseren [MCP-Guide für Komponentenbibliotheken](https://wingo-ui.com/de/blog/component-library-mcp-guide)). Die [Theming-Doku](https://wingo-ui.com/docs/theming) zeigt die wichtigsten Tokens mit ihren hellen und dunklen Werten und die zwei Dark-Mode-Setups, die die CLI unterstützt: eine `.dark`-Klasse oder `prefers-color-scheme`, wenn dein Stylesheet das schon nutzt.

Zum Umgestalten öffnest du den [Theme-Generator](https://wingo-ui.com/theme), wählst eine Markenfarbe, Neutraltöne, Radius, Schrift und Komponenten-Defaults, prüfst seinen Kontrastbericht und wendest den Export an:

```bash
npx wingo-ui@latest theme apply ./theme.json
```

Der Befehl ersetzt die Werte der Tokens, die das Theme setzt, in Light und Dark, ergänzt Tailwind-Mappings für neue Tokens und gibt die `UIProvider`-Defaults und die Locale aus, die du in deinen Provider einfügst.

Wenn du von shadcn/ui kommst: Die gemeinsamen Namen passen zusammen, aber zwei haben eine andere Bedeutung. Unsere `--card` ist eine flache graue Kachel, während sie bei shadcn der Hintergrund der Card ist, und unser `--accent` ist ein oranger Tint. Weil `init` deine bestehenden Werte behält, behält ein shadcn-Projekt seine eigene `--card` und seinen eigenen `--accent`, prüf also nach dem Zusammenführen die weiche Card-Variante. Unser [Vergleich der shadcn-Alternativen](https://wingo-ui.com/de/blog/shadcn-alternatives) behandelt die größeren Unterschiede zwischen den beiden Bibliotheken.

## Welche Fehler machen ein Tailwind v4 Design System kaputt?

Dieselben paar Fehler tauchen in den meisten Token-Dateien auf:

- **`dark:`-Klassen in Komponenten.** Einer Komponente, die `dark:bg-zinc-900` braucht, fehlt ein Token.
- **Palettennamen in Komponenten.** `bg-blue-600` kann keinem Theme folgen; nimm eine Rolle oder einen Tone.
- **Füllfarben als Text.** `text-destructive` auf einer dunklen Fläche fällt bei AA durch; nimm den Soft-Foreground.
- **Werte pro Theme in einfachem `@theme`.** Halte sie in `:root` und `.dark`, gemappt mit `inline`.
- **Ein globaler Radius, der Tailwinds Skala umschreibt.** Jedes eingefügte `rounded-lg` ändert sich mit.
- **Rohe z-index-Zahlen.** Zwei `z-50`-Overlays von zwei Autoren kollidieren.
- **Kein Kontrast-Check.** Tokens driften mit jeder Anpassung; ein Check in CI fängt das noch am selben Tag ab.

## Wo solltest du anfangen?

Führ `npx wingo-ui@latest init` in deinem Next.js-Projekt aus, dann `npx wingo-ui@latest add button`, das die Items Tones und UIProvider gleich mitbringt. Ersetz in einem echten Screen eine fest verdrahtete Farbe durch ein Rollen-Token, öffne ihn in beiden Themes und nimm das Kontrast-Skript in CI auf. Zwei weitere Guides in [Next.js und Tailwind CSS](https://wingo-ui.com/de/blog/category/nextjs-tailwind) behandeln den Komponentencode rund um die Tokens: die [Migration von forwardRef in React 19](https://wingo-ui.com/de/blog/react-19-forwardref-ref-as-prop) und einen [SSR-sicheren React use mobile Hook](https://wingo-ui.com/de/blog/react-use-mobile-hook-ssr), der ohne Mismatch hydriert. Für das Projekt drumherum folgst du dem [Setup einer Next.js-Komponentenbibliothek](https://wingo-ui.com/de/blog/next-js-component-library-setup) für den App Router oder dem [shadcn Vite Setup](https://wingo-ui.com/de/blog/shadcn-vite-setup) für eine Vite-App. Wenn du auf den Tokens eigene Komponenten schreibst, zeigt der Guide zum [React Controllable State Hook](https://wingo-ui.com/de/blog/react-controllable-state-hook), wie ein einziges value-Prop kontrolliert und unkontrolliert funktioniert. Für die Seiten, die sie ausliefern, baut [Next.js JSON-LD](https://wingo-ui.com/de/blog/nextjs-json-ld) typisierte strukturierte Daten, die zur Seite passen, und [Next.js Cookie Consent](https://wingo-ui.com/de/blog/nextjs-cookie-consent) ergänzt ein DSGVO-Banner mit Google Consent Mode v2.

## Komponenten in diesem Artikel

- [Button](https://wingo-ui.com/components/button) (Free): The React button component every screen starts with: five variants, six tones, all sizes, and a loading state that never jumps. Installation: `npx wingo-ui@latest add button`
- [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`
- [UIProvider](https://wingo-ui.com/components/ui-config) (Free): A React config provider for Wingo UI: app-wide default props for every component, the Intl locale and a reduced-motion policy. Installation: `npx wingo-ui@latest add ui-config`
- [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`
- [Badge](https://wingo-ui.com/components/badge) (Free): A React badge for status chips, removable filters and animated counts in three variants and six tones, plus a count or dot on any corner. Installation: `npx wingo-ui@latest add badge`

## Häufige Fragen

### Wohin gehören Design Tokens in Tailwind v4?

In die globale CSS-Datei, nach @import "tailwindcss". Variablen in einem @theme-Block werden zu Utilities (--color-surface ergibt bg-surface), und Werte, die sich pro Theme ändern, stehen in :root und .dark und werden mit @theme inline gemappt. Eine tailwind.config.js brauchst du nicht mehr.

### Was ist der Unterschied zwischen @theme und @theme inline in Tailwind v4?

Mit @theme verweist die Utility auf die Theme-Variable, bg-surface wird also zu var(--color-surface) kompiliert, und das wird einmal auf :root aufgelöst. Mit @theme inline bekommt die Utility den referenzierten Wert direkt, bg-surface wird zu var(--surface) und löst sich auf dem Element auf, das die Klasse nutzt. Nimm inline immer dann, wenn eine Theme-Variable auf eine andere Variable zeigt.

### Sollten Farben in Tailwind v4 OKLCH oder Hex sein?

Beides funktioniert, denn Tokens sind ganz normale CSS-Farben, und das Format ändert den Kontrast nicht. Tailwinds eigene Palette ist mit v4.0 auf oklch umgestiegen. OKLCH hilft vor allem, wenn du Farben im Code ableitest, weil du Hue und Chroma behältst und nur die Helligkeit verschiebst, bis ein Kontrastziel erreicht ist.

### Wie bekommen Tailwind v4 Design Tokens einen Dark Mode?

Definier dieselben Variablen in einem .dark-Block neu und ergänze @custom-variant dark (&:is(.dark *));, damit auch dark:-Utilities der Klasse folgen. next-themes mit attribute="class" setzt die Klasse vor dem ersten Paint. Komponenten mit semantischen Tokens brauchen dann keine dark:-Klassen.

### Wie prüfe ich den Farbkontrast von Design Tokens?

Liste jedes Text-Token mit jeder Fläche auf, auf der es stehen kann, rechne die Werte nach sRGB um und berechne das WCAG-Verhältnis für Light und Dark. Lass den Build unter 4.5:1 für Fließtext scheitern; Fokusringe und andere UI-Formen brauchen 3:1.

### Sind die Design Tokens von Wingo UI kostenlos?

Ja. npx wingo-ui@latest init führt die Tokens ohne Account in dein Stylesheet zusammen, und die Items Button, Card, Tones und UIProvider aus diesem Guide sind kostenlos. Stand Release 1.7.0 sind 75 der 326 Registry-Items kostenlos, der Rest braucht Pro.

---

Quelle: https://wingo-ui.com/de/blog/tailwind-v4-design-tokens
