Zum Inhalt springen
·19 Min. Lesezeit

Tailwind CSS v4: Was sich wirklich geändert hat und ob du migrieren solltest

CSS-first-Konfiguration, @layer-Integration, eingebaute Container Queries, die neue Engine-Performance, Breaking Changes und meine ehrliche Migrationserfahrung von v3 zu v4.

Teilen:X / TwitterLinkedIn

Ich benutze Tailwind CSS seit v1.x, als die halbe Community es für eine Scheußlichkeit hielt und die andere Hälfte nicht aufhören konnte, damit auszuliefern. Jede Major-Version war ein bedeutender Sprung, aber v4 ist anders. Es ist nicht nur ein Feature-Release. Es ist ein kompletter architektonischer Neubau, der den fundamentalen Vertrag zwischen dir und dem Framework ändert.

Nach der Migration von zwei Produktionsprojekten von v3 zu v4 und dem Start von drei neuen Projekten direkt auf v4 habe ich ein klares Bild davon, was wirklich besser ist, was noch rau ist und ob du heute migrieren solltest. Kein Hype, keine Empörung — nur was ich beobachtet habe.

Das große Bild: Was v4 tatsächlich ist#

Tailwind CSS v4 ist drei Dinge gleichzeitig:

  1. Eine neue Engine — komplett in Rust neu geschrieben (die Oxide Engine), die Builds dramatisch schneller macht
  2. Ein neues Konfigurationsparadigma — CSS-first-Konfiguration ersetzt tailwind.config.js als Standard
  3. Eine engere Integration mit der CSS-Plattform — natives @layer, Container Queries, @starting-style und Cascade Layers sind erstklassige Bürger

Die Schlagzeile, die du überall siehst, ist „10x schneller." Das stimmt, aber es unterschätzt die tatsächliche Veränderung. Das mentale Modell für die Konfiguration und Erweiterung von Tailwind hat sich grundlegend verschoben. Du arbeitest jetzt mit CSS, nicht mit einem JavaScript-Konfigurationsobjekt, das CSS generiert.

So sieht ein minimales Tailwind-v4-Setup aus:

css
/* app.css — das ist das gesamte Setup */
@import "tailwindcss";

Das war's. Keine Config-Datei. Keine PostCSS-Plugin-Konfiguration (für die meisten Setups). Keine @tailwind base; @tailwind components; @tailwind utilities;-Direktiven. Ein Import, und du bist startklar.

Vergleiche das mit v3:

css
/* v3 — app.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
js
// v3 — tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    "./src/**/*.{js,ts,jsx,tsx,mdx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
};
js
// v3 — postcss.config.js
module.exports = {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  },
};

Drei Dateien reduziert auf eine Zeile. Das ist nicht nur weniger Boilerplate — es ist weniger Angriffsfläche für Fehlkonfigurationen. In v4 ist die Content-Erkennung automatisch. Es scannt deine Projektdateien, ohne dass du Glob-Muster angeben musst.

CSS-First-Konfiguration mit @theme#

Das ist die größte konzeptionelle Verschiebung. In v3 hast du Tailwind über ein JavaScript-Config-Objekt angepasst:

js
// v3 — tailwind.config.js
module.exports = {
  theme: {
    extend: {
      colors: {
        brand: {
          50: "#eff6ff",
          500: "#3b82f6",
          900: "#1e3a5f",
        },
      },
      fontFamily: {
        display: ["Inter Variable", "sans-serif"],
      },
      spacing: {
        18: "4.5rem",
        112: "28rem",
      },
      borderRadius: {
        "4xl": "2rem",
      },
    },
  },
};

In v4 lebt all das in CSS mit der @theme-Direktive:

css
@import "tailwindcss";
 
@theme {
  --color-brand-50: #eff6ff;
  --color-brand-500: #3b82f6;
  --color-brand-900: #1e3a5f;
 
  --font-display: "Inter Variable", sans-serif;
 
  --spacing-18: 4.5rem;
  --spacing-112: 28rem;
 
  --radius-4xl: 2rem;
}

Anfangs habe ich mich dagegen gesträubt. Ich mochte es, ein einzelnes JavaScript-Objekt zu haben, in dem ich mein gesamtes Design-System sehen konnte. Aber nach einer Woche mit dem CSS-Ansatz habe ich meine Meinung aus drei Gründen geändert:

1. Native CSS Custom Properties werden automatisch bereitgestellt. Jeder Wert, den du in @theme definierst, wird zu einer CSS Custom Property auf :root. Das bedeutet, deine Theme-Werte sind in purem CSS, in CSS Modules, in <style>-Tags, überall wo CSS läuft, zugänglich:

css
/* das bekommst du kostenlos */
:root {
  --color-brand-50: #eff6ff;
  --color-brand-500: #3b82f6;
  --color-brand-900: #1e3a5f;
}
css
/* verwende sie überall — kein Tailwind nötig */
.custom-element {
  border: 2px solid var(--color-brand-500);
}

2. Du kannst CSS-Features innerhalb von @theme verwenden. Media Queries, light-dark(), calc() — echtes CSS funktioniert hier, weil es echtes CSS ist:

css
@theme {
  --color-surface: light-dark(#ffffff, #0a0a0a);
  --color-text: light-dark(#0a0a0a, #fafafa);
  --spacing-container: calc(100vw - 2rem);
}

3. Co-Location mit deinem anderen CSS. Dein Theme, deine benutzerdefinierten Utilities und deine Base-Styles leben alle in derselben Sprache, in derselben Datei wenn du möchtest. Kein Kontextwechsel zwischen „CSS-Welt" und „JavaScript-Config-Welt."

Überschreiben vs. Erweitern des Standard-Themes#

In v3 hattest du theme (ersetzen) vs. theme.extend (zusammenführen). In v4 ist das mentale Modell anders:

css
@import "tailwindcss";
 
/* Das ERWEITERT das Standard-Theme — fügt Brand-Farben neben bestehenden hinzu */
@theme {
  --color-brand-500: #3b82f6;
}

Wenn du einen Namespace komplett ersetzen willst (z. B. alle Standardfarben entfernen), verwendest du @theme mit dem --color-*-Wildcard-Reset:

css
@import "tailwindcss";
 
@theme {
  /* Zuerst alle Standardfarben löschen */
  --color-*: initial;
 
  /* Jetzt nur deine Farben definieren */
  --color-white: #ffffff;
  --color-black: #000000;
  --color-brand-50: #eff6ff;
  --color-brand-500: #3b82f6;
  --color-brand-900: #1e3a5f;
}

Dieses Wildcard-Reset-Muster ist elegant. Du wählst genau aus, welche Teile des Standard-Themes du behalten und welche du ersetzen willst. Du willst alle Standard-Abstände aber eigene Farben? Setze --color-*: initial; zurück und lass Spacing in Ruhe.

Mehrere Theme-Dateien#

Für größere Projekte kannst du dein Theme auf Dateien aufteilen:

css
/* styles/theme/colors.css */
@theme {
  --color-brand-50: #eff6ff;
  --color-brand-100: #dbeafe;
  --color-brand-200: #bfdbfe;
  --color-brand-300: #93c5fd;
  --color-brand-400: #60a5fa;
  --color-brand-500: #3b82f6;
  --color-brand-600: #2563eb;
  --color-brand-700: #1d4ed8;
  --color-brand-800: #1e40af;
  --color-brand-900: #1e3a5f;
  --color-brand-950: #172554;
}
 
/* styles/theme/typography.css */
@theme {
  --font-display: "Inter Variable", sans-serif;
  --font-body: "Source Sans 3 Variable", sans-serif;
  --font-mono: "JetBrains Mono Variable", monospace;
 
  --text-display: 3.5rem;
  --text-display--line-height: 1.1;
  --text-display--letter-spacing: -0.02em;
}
css
/* app.css */
@import "tailwindcss";
@import "./theme/colors.css";
@import "./theme/typography.css";

Das ist viel sauberer als das v3-Muster mit einer riesigen tailwind.config.js oder dem Versuch, sie mit require() aufzuteilen.

Die Oxide Engine: Es ist tatsächlich 10x schneller#

Tailwind v4's Engine ist ein kompletter Neubau in Rust. Sie nennen sie Oxide. Ich war skeptisch gegenüber der „10x schneller"-Behauptung — Marketing-Zahlen überleben selten den Kontakt mit echten Projekten. Also habe ich gebenchmarkt.

Mein Testprojekt: Eine Next.js-App mit 847 Komponenten, 142 Seiten, ungefähr 23.000 Tailwind-Klassenverwendungen.

Metrikv3 (Node)v4 (Oxide)Verbesserung
Initialer Build4.280ms387ms11x
Inkrementell (1 Datei bearbeiten)340ms18ms19x
Vollständiger Rebuild (clean)5.100ms510ms10x
Dev-Server Start3.200ms290ms11x

Die „10x"-Behauptung ist für mein Projekt konservativ. Inkrementelle Builds sind der Bereich, wo es wirklich glänzt — 18 ms bedeutet, es ist im Grunde sofort. Du speicherst eine Datei und der Browser hat die neuen Styles, bevor du die Tabs wechseln kannst.

Warum ist es so viel schneller?#

Drei Gründe:

1. Rust statt JavaScript. Der Kern-CSS-Parser, die Klassenerkennung und die Codegenerierung sind alles natives Rust. Das ist keine „lass uns zum Spaß in Rust umschreiben"-Situation — CSS-Parsing ist genuinely CPU-gebundene Arbeit, bei der nativer Code einen massiven Vorteil gegenüber V8 hat.

2. Kein PostCSS im Hot Path. In v3 war Tailwind ein PostCSS-Plugin. Jeder Build bedeutete: CSS in PostCSS-AST parsen, Tailwind-Plugin ausführen, zurück in CSS-String serialisieren, dann laufen andere PostCSS-Plugins. In v4 hat Tailwind seinen eigenen CSS-Parser, der direkt von der Quelle zur Ausgabe geht. PostCSS wird weiterhin für Kompatibilität unterstützt, aber der primäre Pfad überspringt es komplett.

3. Smartere inkrementelle Verarbeitung. Die neue Engine cached aggressiv. Wenn du eine einzelne Datei bearbeitest, scannt sie nur diese Datei nach Klassennamen und regeneriert nur die CSS-Regeln, die sich geändert haben. Die v3-Engine war schlauer in dieser Hinsicht als die Leute ihr zutrauen (JIT-Modus war bereits inkrementell), aber v4 geht mit feingranularem Dependency-Tracking viel weiter.

Ist Geschwindigkeit wirklich wichtig?#

Ja, aber nicht aus dem Grund, den du erwartest. Für die meisten Projekte war v3's Build-Geschwindigkeit „okay." Du hast ein paar hundert Millisekunden im Dev gewartet. Nicht schmerzhaft.

Die v4-Geschwindigkeit ist wichtig, weil sie Tailwind in deiner Toolchain unsichtbar macht. Wenn Builds unter 20 ms liegen, hörst du auf, an Tailwind als Build-Schritt zu denken. Es wird wie Syntax-Highlighting — immer da, nie im Weg. Dieser psychologische Unterschied ist über einen vollen Entwicklungstag hinweg signifikant.

Native @layer-Integration#

In v3 verwendete Tailwind sein eigenes Layer-System mit @layer base, @layer components und @layer utilities. Diese sahen aus wie CSS Cascade Layers, waren es aber nicht — es waren Tailwind-spezifische Direktiven, die kontrollierten, wo generiertes CSS in der Ausgabe erschien.

In v4 verwendet Tailwind echte CSS Cascade Layers:

css
/* v4 Ausgabe — vereinfacht */
@layer theme, base, components, utilities;
 
@layer base {
  /* Reset, Preflight */
}
 
@layer components {
  /* Deine Komponentenklassen */
}
 
@layer utilities {
  /* Alle generierten Utility-Klassen */
}

Das ist eine signifikante Änderung, weil CSS Cascade Layers echte Spezifitätsauswirkungen haben. Eine Regel in einem niedrig priorisierten Layer verliert immer gegen eine Regel in einem höher priorisierten Layer, unabhängig von der Selektorspezifität. Das bedeutet:

css
@layer components {
  /* Spezifität: 0-1-0 */
  .card { padding: 1rem; }
}
 
@layer utilities {
  /* Spezifität: 0-1-0 — gleiche Spezifität, gewinnt aber weil Utilities-Layer später kommt */
  .p-4 { padding: 1rem; }
}

Utilities überschreiben immer Components. Components überschreiben immer Base. So hat Tailwind konzeptuell in v3 funktioniert, aber jetzt wird es durch den Cascade-Layer-Mechanismus des Browsers durchgesetzt, nicht durch Manipulation der Quellreihenfolge.

Benutzerdefinierte Utilities hinzufügen#

In v3 hast du benutzerdefinierte Utilities mit einer Plugin-API oder @layer utilities definiert:

js
// v3 — Plugin-Ansatz
const plugin = require("tailwindcss/plugin");
 
module.exports = {
  plugins: [
    plugin(function ({ addUtilities }) {
      addUtilities({
        ".text-balance": {
          "text-wrap": "balance",
        },
        ".text-pretty": {
          "text-wrap": "pretty",
        },
      });
    }),
  ],
};

In v4 werden benutzerdefinierte Utilities mit der @utility-Direktive definiert:

css
@import "tailwindcss";
 
@utility text-balance {
  text-wrap: balance;
}
 
@utility text-pretty {
  text-wrap: pretty;
}

Die @utility-Direktive sagt Tailwind „das ist eine Utility-Klasse — platziere sie im Utilities-Layer und erlaube ihre Verwendung mit Variants." Der letzte Teil ist entscheidend. Eine mit @utility definierte Utility funktioniert automatisch mit hover:, focus:, md: und jeder anderen Variante:

html
<p class="text-pretty md:text-balance">...</p>

Benutzerdefinierte Varianten#

Du kannst auch benutzerdefinierte Varianten mit @variant definieren:

css
@import "tailwindcss";
 
@variant hocus (&:hover, &:focus);
@variant theme-dark (.dark &);
html
<button class="hocus:bg-brand-500 theme-dark:text-white">
  Klick mich
</button>

Das ersetzt die v3-addVariant-Plugin-API für die meisten Anwendungsfälle. Es ist weniger mächtig (du kannst keine programmatische Variantengenerierung machen), deckt aber 90 % dessen ab, was die Leute tatsächlich tun.

Container Queries: Eingebaut, kein Plugin#

Container Queries waren eines der meistgewünschten Features in v3. Du konntest sie mit dem @tailwindcss/container-queries-Plugin bekommen, aber es war ein Add-on. In v4 sind sie fest ins Framework integriert.

Grundlegende Verwendung#

Markiere einen Container mit @container und frage seine Größe mit dem @-Präfix ab:

html
<!-- Elternelement als Container markieren -->
<div class="@container">
  <!-- responsiv zum Elternteil, nicht zum Viewport -->
  <div class="flex flex-col @md:flex-row @lg:grid @lg:grid-cols-3">
    <div class="p-4">Karte 1</div>
    <div class="p-4">Karte 2</div>
    <div class="p-4">Karte 3</div>
  </div>
</div>

Die @md-, @lg-usw.-Varianten funktionieren wie responsive Breakpoints, beziehen sich aber auf den nächsten @container-Vorfahren statt auf den Viewport. Die Breakpoint-Werte entsprechen Tailwinds Standard-Breakpoints:

VarianteMin-Breite
@sm24rem (384px)
@md28rem (448px)
@lg32rem (512px)
@xl36rem (576px)
@2xl42rem (672px)

Benannte Container#

Du kannst Container benennen, um bestimmte Vorfahren abzufragen:

html
<div class="@container/sidebar">
  <div class="@container/card">
    <!-- fragt den Card-Container ab -->
    <div class="@md/card:text-lg">...</div>
 
    <!-- fragt den Sidebar-Container ab -->
    <div class="@lg/sidebar:hidden">...</div>
  </div>
</div>

Warum das wichtig ist#

Container Queries verändern, wie du über Responsive Design denkst. Statt „bei dieser Viewport-Breite drei Spalten anzeigen" sagst du „wenn der Container dieser Komponente breit genug ist, drei Spalten anzeigen." Komponenten werden wirklich eigenständig. Du kannst eine Kartenkomponente von einem Full-Width-Layout in eine Sidebar verschieben und sie passt sich automatisch an. Keine Media-Query-Akrobatik.

Ich habe meine Komponentenbibliotheken refaktoriert, um standardmäßig Container Queries statt Viewport-Breakpoints zu verwenden. Das Ergebnis sind Komponenten, die überall funktionieren, wo du sie platzierst, ohne dass das Elternelement etwas über das responsive Verhalten der Komponente wissen muss.

html
<!-- Diese Komponente passt sich an JEDEN Container an -->
<article class="@container">
  <div class="grid grid-cols-1 @md:grid-cols-[200px_1fr] gap-4">
    <img
      class="w-full @md:w-auto rounded-lg aspect-video @md:aspect-square object-cover"
      src="/post-image.jpg"
      alt=""
    />
    <div>
      <h2 class="text-lg @lg:text-xl font-semibold">Beitragstitel</h2>
      <p class="mt-2 text-sm @md:text-base text-gray-600">
        Beitragsauszug hier...
      </p>
      <div class="mt-4 hidden @md:flex gap-2">
        <span class="text-xs bg-gray-100 px-2 py-1 rounded">Tag</span>
      </div>
    </div>
  </div>
</article>

Neue Varianten, die wirklich zählen#

v4 fügt mehrere neue Varianten hinzu, die ich ständig verwende. Sie füllen echte Lücken.

Die starting:-Variante#

Diese bildet auf CSS @starting-style ab, mit dem du den Anfangszustand eines Elements definieren kannst, wenn es zum ersten Mal erscheint. Das fehlende Puzzlestück für Eintrittsanimationen ohne JavaScript:

html
<dialog class="opacity-0 starting:opacity-0 open:opacity-100 transition-opacity duration-300">
  <p>Dieser Dialog blendet beim Öffnen ein</p>
</dialog>

Die starting:-Variante generiert CSS innerhalb eines @starting-style-Blocks:

css
/* was Tailwind generiert */
@starting-style {
  dialog[open] {
    opacity: 0;
  }
}
 
dialog[open] {
  opacity: 1;
  transition: opacity 300ms;
}

Das ist riesig für Dialoge, Popovers, Dropdown-Menüs — alles, was eine Eintrittsanimation braucht. Vorher brauchtest du JavaScript, um im nächsten Frame eine Klasse hinzuzufügen, oder du hast @keyframes verwendet. Jetzt ist es eine Utility-Klasse.

Die not-*-Variante#

Negation. Etwas, das wir uns schon ewig gewünscht haben:

html
<!-- jedes Kind außer dem letzten bekommt einen Border -->
<div class="divide-y">
  <div class="not-last:pb-4">Element 1</div>
  <div class="not-last:pb-4">Element 2</div>
  <div class="not-last:pb-4">Element 3</div>
</div>
 
<!-- alles stylen, was nicht deaktiviert ist -->
<input class="not-disabled:hover:border-brand-500" />
 
<!-- Datenattribute negieren -->
<div class="not-data-active:opacity-50">...</div>

Die nth-*-Varianten#

Direkter nth-child- und nth-of-type-Zugriff:

html
<ul>
  <li class="nth-1:font-bold">Erstes Element — fett</li>
  <li class="nth-even:bg-gray-50">Gerade Zeilen — grauer Hintergrund</li>
  <li class="nth-odd:bg-white">Ungerade Zeilen — weißer Hintergrund</li>
  <li class="nth-[3n+1]:text-brand-500">Jedes dritte+1 — Brand-Farbe</li>
</ul>

Die Klammer-Syntax (nth-[3n+1]) unterstützt jeden gültigen nth-child-Ausdruck. Das ersetzt eine Menge benutzerdefiniertes CSS, das ich früher für Tabellenstreifen und Rastermuster geschrieben habe.

Die in-*-Variante (Elternzustand)#

Das ist die Umkehrung von group-*. Statt „wenn mein Elternteil (Group) gehovert wird, style mich" ist es „wenn ich mich innerhalb eines Elternteils befinde, das diesem Zustand entspricht, style mich":

html
<div class="in-data-active:bg-brand-50">
  Das bekommt einen Hintergrund, wenn ein Vorfahre data-active hat
</div>

Die **:-Deep-Universal-Variante#

Alle Nachkommen stylen, nicht nur direkte Kinder. Das ist kontrollierte Macht — verwende es sparsam, aber es ist unverzichtbar für Prosa-Inhalte und CMS-Ausgaben:

html
<!-- alle Absätze innerhalb dieses Divs, in beliebiger Tiefe -->
<div class="**:data-highlight:bg-yellow-100">
  <section>
    <p data-highlight>Das wird hervorgehoben</p>
    <div>
      <p data-highlight>Das auch, tiefer verschachtelt</p>
    </div>
  </section>
</div>

Breaking Changes: Was wirklich kaputt gegangen ist#

Lass mich offen sein. Wenn du ein großes v3-Projekt hast, ist die Migration nicht trivial. Hier ist, was in meinen Projekten kaputt ging:

1. Konfigurationsformat#

Deine tailwind.config.js funktioniert nicht sofort. Du musst entweder:

  • Sie in @theme-CSS konvertieren (empfohlen für die neue Architektur)
  • Die Kompatibilitätsschicht @config-Direktive verwenden (schneller Migrationspfad)
css
/* schnelle Migration — behalte deine alte Config */
@import "tailwindcss";
@config "../../tailwind.config.js";

Diese @config-Brücke funktioniert, ist aber explizit ein Migrationswerkzeug. Die Empfehlung ist, über die Zeit zu @theme zu wechseln.

2. Entfernte veraltete Utilities#

Einige Utilities, die in v3 veraltet waren, sind weg:

/* ENTFERNT in v4 */
bg-opacity-*     → verwende bg-black/50 (Slash-Opacity-Syntax)
text-opacity-*   → verwende text-black/50
border-opacity-* → verwende border-black/50
flex-shrink-*    → verwende shrink-*
flex-grow-*      → verwende grow-*
overflow-ellipsis → verwende text-ellipsis
decoration-slice  → verwende box-decoration-slice
decoration-clone  → verwende box-decoration-clone

Wenn du in v3 bereits die moderne Syntax verwendet hast (Slash-Opacity, shrink-*), bist du fein raus. Wenn nicht, sind das unkomplizierte Suchen-und-Ersetzen-Änderungen.

3. Änderungen der Standard-Farbpalette#

Die Standard-Farbpalette hat sich leicht verschoben. Wenn du von exakten Farbwerten aus v3 abhängst (nicht nach Name, sondern nach dem tatsächlichen Hex-Wert), bemerkst du möglicherweise visuelle Unterschiede. Die benannten Farben (blue-500, gray-200) existieren noch, aber einige Hex-Werte haben sich geändert.

4. Content-Erkennung#

v3 erforderte explizite content-Konfiguration:

js
// v3
module.exports = {
  content: [
    "./src/**/*.{js,ts,jsx,tsx}",
    "./components/**/*.{js,ts,jsx,tsx}",
  ],
};

v4 verwendet automatische Content-Erkennung. Es scannt deinen Projektstamm und findet Template-Dateien automatisch. Das funktioniert meistens „einfach so", aber bei einer ungewöhnlichen Projektstruktur (Monorepo mit Paketen außerhalb des Projektstamms, Template-Dateien an unerwarteten Stellen) musst du möglicherweise Quellpfade explizit konfigurieren:

css
@import "tailwindcss";
@source "../shared-components/**/*.tsx";
@source "../design-system/src/**/*.tsx";

5. Plugin-API-Änderungen#

Wenn du eigene Plugins geschrieben hast, hat sich die API geändert. Die Funktionen addUtilities, addComponents, addBase und addVariant funktionieren weiterhin über die Kompatibilitätsschicht, aber der idiomatische v4-Ansatz ist CSS-nativ:

js
// v3-Plugin
plugin(function ({ addUtilities, theme }) {
  addUtilities({
    ".scrollbar-hide": {
      "-ms-overflow-style": "none",
      "scrollbar-width": "none",
      "&::-webkit-scrollbar": {
        display: "none",
      },
    },
  });
});
css
/* v4 — einfach CSS */
@utility scrollbar-hide {
  -ms-overflow-style: none;
  scrollbar-width: none;
  &::-webkit-scrollbar {
    display: none;
  }
}

Die meisten First-Party-Plugins (@tailwindcss/typography, @tailwindcss/forms, @tailwindcss/aspect-ratio) haben v4-kompatible Versionen. Bei Drittanbieter-Plugins ist es Glückssache — prüfe ihr Repo vor der Migration.

6. JIT ist der einzige Modus#

In v3 konntest du den JIT-Modus abschalten (obwohl das fast niemand tat). In v4 gibt es keinen Nicht-JIT-Modus. Alles wird immer on-demand generiert. Wenn du aus irgendeinem Grund die alte AOT-Engine (Ahead-of-Time) verwendet hast, ist dieser Pfad weg.

7. Einige Varianten-Syntaxänderungen#

Einige Varianten wurden umbenannt oder haben ihr Verhalten geändert:

html
<!-- v3 -->
<div class="[&>*]:p-4">...</div>
 
<!-- v4 — der >*-Teil verwendet jetzt die Inset-Varianten-Syntax -->
<div class="*:p-4">...</div>

Die Arbitrary-Variant-Syntax [&...] funktioniert weiterhin, aber v4 bietet benannte Alternativen für gängige Muster.

Migrationsleitfaden: Der echte Prozess#

So habe ich tatsächlich migriert, nicht der Happy Path aus der Doku, sondern wie der Prozess wirklich aussah.

Schritt 1: Den offiziellen Codemod ausführen#

Tailwind stellt einen Codemod bereit, der die meisten mechanischen Änderungen erledigt:

bash
npx @tailwindcss/upgrade

Das erledigt viel automatisch:

  • Konvertiert @tailwind-Direktiven zu @import "tailwindcss"
  • Benennt veraltete Utility-Klassen um
  • Aktualisiert Varianten-Syntax
  • Konvertiert Opacity-Utilities zur Slash-Syntax (bg-opacity-50 zu bg-black/50)
  • Erstellt einen grundlegenden @theme-Block aus deiner Config

Was der Codemod gut handhabt#

  • Utility-Klassen-Umbenennungen (nahezu perfekt)
  • Direktiven-Syntaxänderungen
  • Einfache Theme-Werte (Farben, Abstände, Schriften)
  • Opacity-Syntax-Migration

Was der Codemod NICHT handhabt#

  • Komplexe Plugin-Konvertierungen
  • Dynamische Config-Werte (theme()-Aufrufe in JavaScript)
  • Bedingte Theme-Konfiguration (z. B. Theme-Werte basierend auf der Umgebung)
  • Benutzerdefinierte Plugin-API-Migrationen
  • Randfälle bei Arbitrary Values, wo der neue Parser anders interpretiert
  • Klassennamen, die dynamisch in JavaScript konstruiert werden (Template Literals, String-Konkatenation)

Schritt 2: PostCSS-Konfiguration anpassen#

Für die meisten Setups aktualisierst du deine PostCSS-Config:

js
// postcss.config.js — v4
module.exports = {
  plugins: {
    "@tailwindcss/postcss": {},
  },
};

Beachte: Der Plugin-Name hat sich von tailwindcss zu @tailwindcss/postcss geändert. Wenn du Vite verwendest, kannst du PostCSS komplett überspringen und das Vite-Plugin nutzen:

js
// vite.config.ts
import tailwindcss from "@tailwindcss/vite";
 
export default defineConfig({
  plugins: [tailwindcss()],
});

Schritt 3: Theme-Konfiguration konvertieren#

Das ist der manuelle Teil. Nimm deine tailwind.config.js-Theme-Werte und konvertiere sie zu @theme:

js
// v3-Config — vorher
module.exports = {
  theme: {
    extend: {
      colors: {
        brand: {
          light: "#60a5fa",
          DEFAULT: "#3b82f6",
          dark: "#1d4ed8",
        },
      },
      fontSize: {
        "2xs": ["0.65rem", { lineHeight: "1rem" }],
      },
      animation: {
        "fade-in": "fade-in 0.5s ease-out",
      },
      keyframes: {
        "fade-in": {
          "0%": { opacity: "0" },
          "100%": { opacity: "1" },
        },
      },
    },
  },
};
css
/* v4-CSS — nachher */
@import "tailwindcss";
 
@theme {
  --color-brand-light: #60a5fa;
  --color-brand: #3b82f6;
  --color-brand-dark: #1d4ed8;
 
  --text-2xs: 0.65rem;
  --text-2xs--line-height: 1rem;
 
  --animate-fade-in: fade-in 0.5s ease-out;
}
 
@keyframes fade-in {
  0% { opacity: 0; }
  100% { opacity: 1; }
}

Beachte, dass Keyframes aus @theme herausrücken und zu regulären CSS-@keyframes werden. Der Animationsname in @theme referenziert sie nur. Das ist sauberer — Keyframes sind CSS, sie sollten als CSS geschrieben werden.

Schritt 4: Visuelle Regressionstests#

Das ist nicht verhandelbar. Nach der Migration habe ich jede Seite meiner App geöffnet und visuell geprüft. Ich habe auch meine Playwright-Screenshot-Tests ausgeführt (falls vorhanden). Der Codemod ist gut, aber nicht perfekt. Dinge, die ich bei der visuellen Überprüfung gefunden habe:

  • Einige Stellen, wo die Opacity-Syntax-Migration leicht andere Ergebnisse lieferte
  • Benutzerdefinierte Plugin-Ausgabe, die nicht übernommen wurde
  • Z-Index-Stacking-Änderungen durch Layer-Reihenfolge
  • Einige !important-Overrides, die sich mit Cascade Layers anders verhielten

Schritt 5: Drittanbieter-Abhängigkeiten aktualisieren#

Prüfe jedes Tailwind-bezogene Paket:

json
{
  "@tailwindcss/typography": "^1.0.0",
  "@tailwindcss/forms": "^1.0.0",
  "@tailwindcss/container-queries": "ENTFERNEN — jetzt eingebaut",
  "tailwindcss-animate": "auf v4-Support prüfen",
  "prettier-plugin-tailwindcss": "auf neueste Version updaten"
}

Das @tailwindcss/container-queries-Plugin wird nicht mehr benötigt — Container Queries sind eingebaut. Andere Plugins brauchen ihre v4-kompatiblen Versionen.

Arbeiten mit Next.js#

Da ich Next.js für die meisten Projekte verwende, hier das spezifische Setup.

PostCSS-Ansatz (empfohlen für Next.js)#

Next.js verwendet PostCSS intern, daher ist das PostCSS-Plugin die natürliche Wahl:

bash
npm install tailwindcss @tailwindcss/postcss
js
// postcss.config.mjs
export default {
  plugins: {
    "@tailwindcss/postcss": {},
  },
};
css
/* app/globals.css */
@import "tailwindcss";
 
@theme {
  --font-sans: "Inter Variable", ui-sans-serif, system-ui, sans-serif;
  --font-mono: "JetBrains Mono Variable", ui-monospace, monospace;
}

Das ist das komplette Setup. Keine tailwind.config.js, kein autoprefixer (v4 handhabt Vendor Prefixes intern).

CSS-Import-Reihenfolge#

Eine Sache, die mich gestolpert hat: Die CSS-Import-Reihenfolge ist in v4 wichtiger wegen Cascade Layers. Dein @import "tailwindcss" sollte vor deinen benutzerdefinierten Styles kommen:

css
/* korrekte Reihenfolge */
@import "tailwindcss";
@import "./theme.css";
@import "./custom-utilities.css";
 
/* deine Inline-@theme, @utility usw. */

Wenn du benutzerdefiniertes CSS vor Tailwind importierst, können deine Styles in einem niedrigeren Cascade Layer landen und unerwartet überschrieben werden.

Dark Mode#

Dark Mode funktioniert konzeptuell gleich, aber die Konfiguration ist zu CSS gewandert:

css
@import "tailwindcss";
 
/* Klassenbasierten Dark Mode verwenden (Standard ist medienbasiert) */
@variant dark (&:where(.dark, .dark *));

Das ersetzt die v3-Config:

js
// v3
module.exports = {
  darkMode: "class",
};

Der @variant-Ansatz ist flexibler. Du kannst Dark Mode definieren, wie du willst — klassenbasiert, Datenattribut-basiert oder Media-Query-basiert:

css
/* Datenattribut-Ansatz */
@variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
 
/* Media Query — das ist der Standard, du musst es nicht deklarieren */
@variant dark (@media (prefers-color-scheme: dark));

Turbopack-Kompatibilität#

Wenn du Next.js mit Turbopack verwendest (der jetzt der Standard-Dev-Bundler ist), funktioniert v4 hervorragend. Die Rust-Engine harmoniert gut mit Turbopacks eigener Rust-basierter Architektur. Ich habe Dev-Startzeiten gemessen:

Setupv3 + Webpackv3 + Turbopackv4 + Turbopack
Cold Start4,8s2,1s1,3s
HMR (CSS-Änderung)450ms180ms40ms

Die 40 ms HMR für CSS-Änderungen sind kaum wahrnehmbar. Es fühlt sich sofort an.

Performance Deep Dive: Jenseits der Build-Geschwindigkeit#

Die Vorteile der Oxide Engine gehen über die reine Build-Geschwindigkeit hinaus.

Speicherverbrauch#

v4 verbraucht deutlich weniger Speicher. In meinem 847-Komponenten-Projekt:

Metrikv3v4
Spitzenspeicher (Build)380MB45MB
Steady-State (Dev)210MB28MB

Das ist wichtig für CI/CD-Pipelines, wo Speicher begrenzt ist, und für Entwicklungsmaschinen, die zehn Prozesse gleichzeitig laufen.

CSS-Ausgabegröße#

v4 generiert etwas kleinere CSS-Ausgaben, weil die neue Engine besser bei Deduplizierung und Dead-Code-Eliminierung ist:

v3-Ausgabe: 34,2 KB (gzipped)
v4-Ausgabe: 29,8 KB (gzipped)

Eine 13 %-Reduktion ohne Codeänderung. Nicht transformativ, aber kostenlose Performance.

Tree Shaking von Theme-Werten#

In v4 werden, wenn du einen Theme-Wert definierst, ihn aber nie in deinen Templates verwendest, die entsprechenden CSS Custom Properties trotzdem emittiert (sie sind in @theme, das auf :root-Variablen abgebildet wird). Die Utility-Klassen für unbenutzte Werte werden jedoch nicht generiert. Das ist dasselbe wie v3's JIT-Verhalten, aber erwähnenswert: Deine CSS Custom Properties sind immer verfügbar, auch für Werte ohne Utility-Nutzung.

Wenn du verhindern möchtest, dass bestimmte Theme-Werte CSS Custom Properties generieren, kannst du @theme inline verwenden:

css
@theme inline {
  /* Diese Werte generieren Utilities, aber KEINE CSS Custom Properties */
  --color-internal-debug: #ff00ff;
  --spacing-magic-number: 3.7rem;
}

Das ist nützlich für interne Design-Tokens, die du nicht als CSS-Variablen bereitstellen willst.

Fortgeschritten: Themes für Multi-Brand komponieren#

Ein Muster, das v4 erheblich erleichtert, ist Multi-Brand-Theming. Da Theme-Werte CSS Custom Properties sind, kannst du sie zur Laufzeit austauschen:

css
@import "tailwindcss";
 
@theme {
  --color-brand: var(--brand-primary, #3b82f6);
  --color-brand-light: var(--brand-light, #60a5fa);
  --color-brand-dark: var(--brand-dark, #1d4ed8);
}
 
/* Marken-Overrides */
.theme-acme {
  --brand-primary: #e11d48;
  --brand-light: #fb7185;
  --brand-dark: #9f1239;
}
 
.theme-globex {
  --brand-primary: #059669;
  --brand-light: #34d399;
  --brand-dark: #047857;
}
html
<body class="theme-acme">
  <!-- alle bg-brand, text-brand usw. verwenden Acme-Farben -->
  <div class="bg-brand text-white">Acme Corp</div>
</body>

In v3 erforderte das ein benutzerdefiniertes Plugin oder ein komplexes CSS-Variablen-Setup außerhalb von Tailwind. In v4 ist es natürlich — das Theme besteht aus CSS-Variablen, und CSS-Variablen kaskadieren. So etwas fühlt sich mit dem CSS-first-Ansatz richtig an.

Was ich von v3 vermisse#

Lass mich ausgewogen sein. Es gibt Dinge, die v3 getan hat und die ich in v4 wirklich vermisse:

1. JavaScript-Config für programmatische Themes. Ich hatte ein Projekt, bei dem wir Farbskalen aus einer einzelnen Markenfarbe mit einer JavaScript-Funktion in der Config generiert haben. In v4 geht das in @theme nicht — du bräuchtest einen Build-Schritt, der die CSS-Datei generiert, oder du berechnest die Farben einmal und fügst sie ein. Die @config-Kompatibilitätsschicht hilft, ist aber nicht die Langzeitlösung.

2. IntelliSense war beim Launch besser. Die v3 VS Code-Extension hatte Jahre der Politur. v4-IntelliSense funktioniert, hatte aber anfangs einige Lücken — benutzerdefinierte @theme-Werte wurden manchmal nicht autovervollständigt, und @utility-Definitionen wurden nicht immer erkannt. Das hat sich mit neueren Updates erheblich verbessert, ist aber erwähnenswert.

3. Ökosystem-Reife. Das Ökosystem um v3 war riesig. Headless UI, Radix, shadcn/ui, Flowbite, DaisyUI — alles war gegen v3 getestet. v4-Support wird ausgerollt, ist aber noch nicht überall. Ich musste einen PR bei einer Komponentenbibliothek einreichen, um v4-Kompatibilität zu beheben.

Solltest du migrieren?#

Hier ist mein Entscheidungsrahmen nach einigen Wochen mit v4:

Jetzt migrieren, wenn:#

  • Du ein neues Projekt startest (offensichtliche Wahl — starte mit v4)
  • Dein Projekt minimale benutzerdefinierte Plugins hat
  • Du die Performance-Vorteile für große Projekte willst
  • Du bereits moderne Tailwind-Muster verwendest (Slash-Opacity, shrink-* usw.)
  • Du Container Queries brauchst und lieber kein Plugin hinzufügen möchtest

Warten, wenn:#

  • Du stark von Drittanbieter-Tailwind-Plugins abhängst, die v4 noch nicht unterstützen
  • Du komplexe programmatische Theme-Konfiguration hast
  • Dein Projekt stabil ist und nicht aktiv entwickelt wird (warum anfassen?)
  • Du mitten in einem Feature-Sprint bist (zwischen Sprints migrieren, nicht währenddessen)

Nicht migrieren, wenn:#

  • Du auf v2 oder früher bist (erst auf v3 upgraden, stabilisieren, dann v4 erwägen)
  • Dein Projekt in den nächsten Monaten endet (nicht den Aufwand wert)

Meine ehrliche Einschätzung#

Für neue Projekte ist v4 die offensichtliche Wahl. Die CSS-first-Konfiguration ist sauberer, die Engine ist dramatisch schneller, und die neuen Features (Container Queries, @starting-style, neue Varianten) sind wirklich nützlich.

Für bestehende Projekte empfehle ich einen gestuften Ansatz:

  1. Jetzt: Jedes neue Projekt auf v4 starten
  2. Bald: Experimentieren, indem du ein kleines internes Projekt auf v4 konvertierst
  3. Wenn bereit: Produktionsprojekte während eines ruhigen Sprints migrieren, mit visuellen Regressionstests

Die Migration ist nicht schmerzhaft, wenn du dich darauf vorbereitest. Der Codemod erledigt 80 % der Arbeit. Die verbleibenden 20 % sind manuell, aber unkompliziert. Plane einen Tag für ein mittleres Projekt ein, zwei bis drei Tage für ein großes.

Tailwind v4 ist das, was Tailwind schon immer hätte sein sollen. Die JavaScript-Konfiguration war immer ein Zugeständnis an die Werkzeuge seiner Zeit. CSS-first-Konfiguration, native Cascade Layers, eine Rust-Engine — das sind keine Trends, sondern das Framework, das zur Plattform aufschließt. Die Webplattform ist besser geworden, und Tailwind v4 lehnt sich hinein, statt dagegen zu kämpfen.

Der Schritt, deine Design-Tokens in CSS zu schreiben, sie mit CSS-Features zu komponieren und den Cascade des Browsers die Spezifität handhaben zu lassen — das ist die richtige Richtung. Es hat vier Major-Versionen gebraucht, um hierher zu kommen, aber das Ergebnis ist die kohärenteste Version von Tailwind bisher.

Starte dein nächstes Projekt damit. Du wirst nicht zurückblicken.

Ähnliche Beiträge