</>
{ }
React · Tailwind CSS · clsx · CVA · Design-System
React + Tailwind: cn-Utility,
clsx und class-variance-authority

String-Konkatenation für Tailwind-Klassen ist fehleranfällig, schwer lesbar und skaliert nicht zu einem echten Design-System. Die Kombination aus clsx, tailwind-merge und class-variance-authority (CVA) löst dieses Problem elegant: typsichere Varianten, konfliktsichere Klassen-Merge und eine einzige cn-Utility für die gesamte Codebase.

14 Min. Lesezeit clsx · tailwind-merge · CVA · cn · Radix UI · shadcn/ui React 18 · Tailwind CSS v4 · TypeScript

1. Das Problem mit Tailwind-Klassen in React-Komponenten

Tailwind CSS löst das CSS-Skalierungsproblem durch Utility-First-Klassen. React-Komponenten lösen das UI-Kompositionsproblem durch Kapselung und Props. Beide Konzepte zusammen zu bringen ist zunächst einfach — bis eine Komponente mehrere Varianten, Größen und Zustände haben soll. Ein Button in primary und secondary Variante, in small, medium und large, deaktiviert, loading, mit Icon — das sind schnell Dutzende Kombinationen. Mit String-Konkatenation wie {`btn ${variant === 'primary' ? 'bg-blue-600' : 'bg-gray-200'} ${size === 'lg' ? 'px-6 py-3' : 'px-4 py-2'}`} wird der Code schnell unlesbar und fehleranfällig.

Das zweite Problem ist der Tailwind-Klassen-Konflikt. Wenn eine übergeordnete Komponente einen Padding-Override übergeben will — className="p-0" — aber die Komponente intern bereits p-4 hat, dann enthält das finale DOM-Element beide Klassen: p-4 p-0. Tailwind produziert in diesem Fall unvorhersehbares Verhalten, weil die Reihenfolge im CSS-Bundle entscheidet, nicht die Reihenfolge im HTML-Attribut. Das ist ein fundamentales Problem, das mit reinem Tailwind und String-Konkatenation nicht lösbar ist — tailwind-merge löst es.

2. clsx: bedingte Klassen lesbar schreiben

clsx ist eine winzige Bibliothek (unter 300 Byte), die verschiedene Eingaben zu einem Klassen-String zusammenführt. Sie akzeptiert Strings, Objekte und Arrays — und filtert falsy-Werte (undefined, null, false) automatisch heraus. Das erlaubt es, bedingte Klassen als Objekte zu schreiben: clsx({ 'bg-blue-600': isPrimary, 'bg-gray-200': !isPrimary }). Das ist lesbarer, wartbarer und weniger fehleranfällig als Ternär-Ausdrücke in Template-Literals. Arrays ermöglichen das Zusammenführen mehrerer Klassen-Quellen: Basis-Klassen, Varianten-Klassen und externe Props-Klassen.

Ein wichtiger Anwendungsfall für clsx ist das Zusammenführen von internen Klassen mit einem optionalen className-Props. Jede wiederverwendbare Komponente sollte einen className-Props akzeptieren, damit Nutzer der Komponente fine-grained Anpassungen vornehmen können, ohne die gesamte Komponente zu überschreiben. Mit clsx(baseClasses, className) werden beide Quellen zu einem einzigen String zusammengeführt — aber noch ohne Konflikt-Auflösung, was das Problem aus Abschnitt 1 noch nicht vollständig löst.

3. tailwind-merge: Klassen-Konflikte auflösen

tailwind-merge versteht die Semantik von Tailwind-Klassen und löst Konflikte auf, indem es spätere Klassen gegenüber früheren priorisiert. twMerge('p-4 p-0') ergibt 'p-0' — die spätere Klasse gewinnt, genau wie CSS-Kaskadierung funktioniert. Das ermöglicht es Komponenten-Nutzern, Tailwind-Klassen über den className-Props zu überschreiben — eine fundamentale Voraussetzung für ein flexibles Design-System. Ohne tailwind-merge ist jede Klassen-Override unzuverlässig, weil die Reihenfolge im CSS-Bundle über das Ergebnis entscheidet, nicht die Props-Reihenfolge.

tailwind-merge unterstützt alle Standard-Tailwind-Klassen inklusive Responsive-Prefixes (sm:p-4), Dark-Mode (dark:bg-slate-800), State-Modifiers (hover:bg-blue-700) und Custom-Values (p-[13px]). Für Projekte mit benutzerdefinierten Tailwind-Konfigurationen kann man extendTailwindMerge nutzen, um eigene Klassen-Gruppen zu definieren, die ebenfalls korrekt gemergt werden. Der Performance-Overhead von tailwind-merge ist vernachlässigbar — die Bibliothek nutzt intern Caching und ist auf Laufzeit-Effizienz optimiert.


// lib/utils.ts — the cn utility used throughout the entire codebase
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';

/**
 * Combines clsx (conditional classes) with tailwind-merge (conflict resolution).
 * Use this instead of raw clsx or string concatenation for all Tailwind classes.
 */
export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

// Usage examples:
// cn('p-4 text-sm', 'p-0')           → 'text-sm p-0'   (conflict resolved)
// cn('bg-blue-600', { 'opacity-50': isDisabled })  → conditional class
// cn(baseStyles, variantStyles, className)          → merge from all sources
// cn(['px-4 py-2', 'text-sm'], 'font-bold')        → array + string

// Component with className override support
interface BadgeProps {
  children: React.ReactNode;
  variant?: 'default' | 'success' | 'warning';
  className?: string; // allow consumers to override any class
}

const Badge: React.FC<BadgeProps> = ({ children, variant = 'default', className }) => (
  <span
    className={cn(
      // Base styles always applied
      'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
      // Variant styles — only one applies
      variant === 'default' && 'bg-slate-100 text-slate-700',
      variant === 'success' && 'bg-green-100 text-green-700',
      variant === 'warning' && 'bg-yellow-100 text-yellow-700',
      // Consumer override — twMerge resolves any conflicts
      className
    )}
  >
    {children}
  </span>
);

4. Die cn-Utility: clsx + tailwind-merge kombiniert

Die cn-Funktion ist die zentrale Utility für jedes React-Tailwind-Projekt. Sie kombiniert clsx für die bedingte Klassen-Komposition mit tailwind-merge für die Konflikt-Auflösung in einer einzigen Funktion. Diese Funktion gehört in eine zentrale lib/utils.ts-Datei und wird in jeder Komponente importiert, die Tailwind-Klassen zusammensetzt. Das ist das De-facto-Standard-Pattern der React-Tailwind-Community, verwendet von shadcn/ui, Radix UI Themes und hunderten Open-Source-Komponentenbibliotheken.

Ein wichtiger Stil-Vorteil der cn-Utility: Sie erlaubt es, Klassen auf mehrere Zeilen aufzuteilen und damit verschiedene Verantwortlichkeiten klar zu trennen — Layout-Klassen, Typografie-Klassen, Color-Klassen und State-Klassen stehen getrennt und sind auf einen Blick verständlich. Bei komplexen Komponenten mit 20+ Tailwind-Klassen ist das kein Luxus, sondern Notwendigkeit für Wartbarkeit. Prettier-Plugins wie prettier-plugin-tailwindcss sortieren die Klassen innerhalb jedes cn()-Aufrufs alphabetisch nach Tailwind-Konvention.

5. class-variance-authority: variante Komponenten

class-variance-authority (CVA) ist die Lösung für Komponenten mit mehreren Varianten, Größen und Zuständen. Anstatt eine wachsende Reihe von If-Else-Bedingungen oder einem riesigen Objekt-Lookup zu pflegen, definiert man die Varianten deklarativ. CVA erzeugt eine Funktion, die Props akzeptiert und den vollständigen Klassen-String zurückgibt — vollständig typinferiert über TypeScript. Wenn man eine neue Variante hinzufügt, muss man sie nur an einer Stelle definieren, und TypeScript zeigt automatisch alle Stellen, die diese Variante nicht explizit handhaben.

CVA trennt die Varianten-Logik von der Komponenten-Logik. Das ermöglicht es, Varianten-Konfigurationen zu exportieren und in Tests zu prüfen, ohne eine React-Komponente zu rendern. In komplexen Design-Systemen mit Dutzenden von Komponenten ist diese Trennung entscheidend für Testbarkeit und Dokumentierbarkeit. Storybook-Addons können CVA-Varianten automatisch als Steuerelemente erkennen und generieren, was die Komponenten-Dokumentation erheblich vereinfacht.


// components/Button.tsx — class-variance-authority with full TypeScript inference
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';

// Define all variants in one place — TypeScript infers the types automatically
const buttonVariants = cva(
  // Base classes applied to all variants
  'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
  {
    variants: {
      variant: {
        default: 'bg-sky-700 text-white hover:bg-sky-800 focus-visible:ring-sky-600',
        destructive: 'bg-red-600 text-white hover:bg-red-700 focus-visible:ring-red-500',
        outline: 'border border-slate-300 bg-white text-slate-900 hover:bg-slate-50',
        ghost: 'text-slate-700 hover:bg-slate-100 hover:text-slate-900',
        link: 'text-sky-700 underline-offset-4 hover:underline',
      },
      size: {
        sm: 'h-8 px-3 text-xs',
        md: 'h-10 px-4',
        lg: 'h-12 px-6 text-base',
        icon: 'h-10 w-10',
      },
    },
    defaultVariants: {
      variant: 'default',
      size: 'md',
    },
  }
);

// Merge CVA variants with HTML button props + optional className override
interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  isLoading?: boolean;
}

export const Button: React.FC<ButtonProps> = ({
  className,
  variant,
  size,
  isLoading,
  children,
  ...props
}) => (
  <button
    className={cn(buttonVariants({ variant, size }), className)}
    disabled={isLoading || props.disabled}
    {...props}
  >
    {isLoading && <span className="animate-spin">⟳</span>}
    {children}
  </button>
);

export { buttonVariants };

6. Compound Variants: kombinierte Zustände

Compound Variants sind ein CVA-Feature für Klassen, die nur aktiv sind, wenn mehrere Varianten gleichzeitig einen bestimmten Wert haben. Das klassische Beispiel: ein Button in der outline-Variante und der destructive-Farbe braucht andere Klassen als beide Varianten einzeln. Mit regulären Varianten müsste man das durch verschachtelte Bedingungen abbilden — mit Compound Variants deklariert man es direkt: "Wenn variant === 'outline' UND color === 'destructive', füge diese Klassen hinzu." Das hält die Varianten-Konfiguration flach und verständlich, auch bei komplexen Zustandskombinationen.

Ein weiteres Anwendungsfeld für Compound Variants sind zustandsabhängige Animationen. Ein Spinner-Element, das nur bei size === 'sm' und isLoading === true eine bestimmte Größe bekommt, lässt sich als Compound Variant definieren, ohne die Basis-Klassen zu verkomplizieren. CVA garantiert, dass diese Kombinationen vollständig typsicher sind — der TypeScript-Compiler warnt sofort, wenn eine Prop-Kombination übergeben wird, die nicht definiert ist.

7. shadcn/ui als CVA-Pattern in der Praxis

shadcn/ui ist die populärste React-Komponentenbibliothek, die CVA, clsx und tailwind-merge als Grundlage verwendet. Der entscheidende Unterschied zu anderen Bibliotheken: shadcn/ui kopiert Komponenten in das eigene Projekt, statt sie als NPM-Paket zu installieren. Das gibt Teams volle Kontrolle über jede Komponente — man kann Varianten hinzufügen, Klassen anpassen und Verhalten ändern, ohne eine Library zu forken. Die Komponenten sind gut lesbare CVA-Implementierungen, die gleichzeitig als Lernmaterial für das Pattern dienen.

Das shadcn/ui-CLI-Tool generiert Komponenten direkt in den components/ui/-Ordner. Jede Komponente bringt die benötigten NPM-Dependencies mit — typischerweise @radix-ui/* für Accessibility-Primitives und class-variance-authority für Varianten. Radix UI liefert das funktionale Fundament (Keyboard-Navigation, ARIA-Attribute, Focus-Management), shadcn/ui fügt das Styling via CVA hinzu. Wer dieses Muster versteht, kann eigene Design-System-Komponenten nach demselben Prinzip bauen.

8. Design-Tokens und Tailwind v4

Tailwind CSS v4 wechselt von der tailwind.config.js zur CSS-first-Konfiguration mit CSS Custom Properties. Design-Tokens werden direkt in CSS definiert: --color-primary: oklch(54% 0.2 250). Das vereinfacht die Integration mit Design-Tools wie Figma, die Tokens als CSS-Variablen exportieren. Im Zusammenspiel mit CVA bedeutet das: Varianten-Klassen nutzen Token-basierte Farben (text-primary bg-primary/10), und ein Farb-Update im CSS-Token aktualisiert automatisch alle Komponenten, die diesen Token nutzen — ohne CVA-Konfigurationen anzufassen.

Das Zusammenspiel von CVA und Tailwind v4 CSS-Variablen ermöglicht Multi-Theming ohne JavaScript. Verschiedene CSS-Klassen auf dem html-Element (class="theme-corporate", class="theme-minimal") schalten auf andere CSS-Variable-Werte um, die CVA-Varianten erben das automatisch. Das ist ein erheblicher Vorteil gegenüber CSS-in-JS-Lösungen: Zero-Runtime-Overhead, volle SSR-Kompatibilität und direkte Steuerung über CSS — ohne React-State oder Context für Theming.


/* CSS-first design tokens — Tailwind v4 approach */
@import "tailwindcss";

@theme {
  /* Brand colors as CSS custom properties */
  --color-brand-500: oklch(54% 0.2 250);
  --color-brand-600: oklch(47% 0.22 250);
  --color-brand-700: oklch(40% 0.24 250);

  /* Semantic tokens — reference brand colors */
  --color-primary: var(--color-brand-600);
  --color-primary-hover: var(--color-brand-700);

  /* Typography scale */
  --font-size-base: 1rem;
  --line-height-base: 1.5;
}

/* Theme override — swap tokens without touching components */
.theme-warm {
  --color-primary: oklch(54% 0.22 40);  /* warm orange */
  --color-primary-hover: oklch(47% 0.24 40);
}

9. Styling-Ansätze im Vergleich

Die Wahl des Styling-Ansatzes für React-Projekte beeinflusst Performance, Entwicklungsgeschwindigkeit und langfristige Wartbarkeit. Tailwind mit CVA ist nicht für jeden Anwendungsfall die beste Wahl — aber für die meisten React-Projekte die pragmatischste.

Ansatz Bundle-Größe Typsicherheit Design-System-Eignung
Tailwind + CVA + cn Sehr klein (PurgeCSS) Vollständig via VariantProps Sehr gut
CSS Modules Klein Begrenzt Gut (mit Typgenerator)
styled-components Groß (Runtime CSS-in-JS) Vollständig Gut
Vanilla Extract Klein (Compile-Zeit) Vollständig Gut (komplex)
Inline-Styles Minimal Teilweise Schlecht (kein Hover/Focus)

Tailwind mit CVA dominiert in der Kombination aus kleinem Bundle, vollständiger Typsicherheit und guter Design-System-Eignung. Der entscheidende Vorteil gegenüber CSS-in-JS-Lösungen: Zero-Runtime-Overhead, weil alle Styles zur Build-Zeit erzeugt werden. Im Vergleich zu CSS Modules ist CVA typsicherer und erlaubt Varianten-Komposition ohne manuelle TypeScript-Definitionen. Vanilla Extract ist eine valide Alternative für Teams, die mehr CSS-Semantik und weniger Utility-Klassen bevorzugen.

Mironsoft

React-Design-Systeme mit Tailwind, CVA und TypeScript

Design-System mit React und Tailwind aufbauen?

Wir planen und implementieren euer React-Komponentensystem mit CVA, cn-Utility und Tailwind v4 — skalierbar, typsicher und wartbar ohne Copy-Paste-Styling.

Komponenten-Bibliothek

CVA-basierte Varianten-Komponenten mit vollständiger TypeScript-Typisierung

Design-Tokens

Tailwind v4 CSS-first-Tokens mit Theming-Support und Figma-Integration

Storybook-Dokumentation

CVA-Varianten als Storybook-Controls — alle Zustände auf einem Blick

10. Zusammenfassung

Das Zusammenspiel von clsx, tailwind-merge und class-variance-authority löst die drei größten Probleme bei der Kombination von React und Tailwind CSS: bedingte Klassen lesbar schreiben, Klassen-Konflikte beim Überschreiben auflösen, und Komponenten-Varianten typsicher und wartbar definieren. Die cn-Utility ist die zentrale Abstraktion, die clsx und tailwind-merge kombiniert — eine einzige Funktion für alle Klassen-Operationen in der gesamten Codebase. CVA mit VariantProps macht Komponenten vollständig typsicher: TypeScript kennt alle erlaubten Varianten und warnt bei ungültigen Kombinationen.

shadcn/ui hat diese Muster in der Community popularisiert und zeigt, wie sie in einer produktionsreifen Komponentenbibliothek zusammenspielen. Wer die drei Schichten versteht — Accessibility-Primitives (Radix UI), Varianten-Styling (CVA + cn) und Design-Tokens (Tailwind v4 CSS Custom Properties) — kann eigene Komponentenbibliotheken nach demselben Muster bauen: wartbar, typsicher und ohne Runtime-Overhead durch CSS-in-JS.

React + Tailwind + CVA — Das Wichtigste auf einen Blick

cn-Utility zuerst

lib/utils.ts mit cn = twMerge(clsx(...)) anlegen — diese eine Funktion für alle Tailwind-Klassen-Operationen nutzen, nie direkt konkatenieren.

CVA für Varianten

cva() definiert alle Varianten deklarativ. VariantProps<typeof xVariants> gibt TypeScript-Typen kostenlos — keine manuellen Interface-Definitionen.

className-Props immer

Jede wiederverwendbare Komponente akzeptiert className-Props und mergt ihn mit cn() — Nutzer können jeden Tailwind-Override übergeben.

Tokens in CSS-Vars

Tailwind v4 CSS-Custom-Properties für Design-Tokens — Theming ohne JavaScript-Runtime, volle SSR-Kompatibilität.

11. FAQ: React + Tailwind mit cn, clsx und CVA

1Unterschied clsx vs. classnames?
clsx ist der kleinere, schnellere Nachfolger ohne Dependencies — unter 300 Byte. Gleiches API, für neue Projekte immer clsx wählen.
2Warum reicht clsx allein nicht?
clsx löst keine Konflikte auf. "p-4 p-0" bleibt "p-4 p-0" — welche gewinnt, hängt von der CSS-Bundle-Reihenfolge ab. tailwind-merge macht "p-0" daraus.
3Was ist class-variance-authority?
Deklarative, typsichere Varianten-Definitionen für Komponenten. cva() erzeugt eine Funktion, TypeScript inferiert alle Varianten-Typen automatisch.
4Was sind Compound Variants?
Klassen die nur aktiv werden wenn mehrere Varianten gleichzeitig bestimmte Werte haben — deklarativ, typsicher, ohne verschachtelte Bedingungen.
5Warum shadcn/ui CVA und tailwind-merge?
Pragmatischstes Pattern: CVA für typsichere Varianten, tailwind-merge für überschreibbare Klassen. shadcn/ui kopiert Komponenten ins Projekt — volle Kontrolle bleibt beim Team.
6Tailwind v4 mit CSS Custom Properties?
@theme { --color-primary: oklch(...) } definiert Token direkt in CSS. Theming via CSS-Klassen auf html — kein JavaScript, kein Runtime-Overhead.
7className-Props in jeder Komponente?
Ja, für alle wiederverwendbaren Komponenten. Mit cn() korrekt gemergt und Konflikte aufgelöst — Nutzer können jeden Tailwind-Override übergeben.
8Tailwind-Klassen automatisch sortieren?
prettier-plugin-tailwindcss in .prettierrc eintragen — sortiert auch innerhalb von cn()- und cva()-Aufrufen automatisch.
9CVA-Varianten testen?
cva()-Funktion direkt in Unit-Tests aufrufen ohne React — buttonVariants({ variant: 'outline' }) gibt den Klassen-String zurück, den man mit toEqual prüft.
10CVA ohne Tailwind möglich?
Ja. CVA ist framework-agnostisch und generiert nur Strings. Mit CSS Modules, UnoCSS oder BEM-Klassen nutzbar. tailwind-merge ist Tailwind-spezifisch.