Tutorials

Dynamisches Farb-Theming in React: Jenseits von CSS-Variablen

9 Min. Lesezeit

Die meisten Theming-Tutorials enden beim Dark-Mode-Toggle. Eine Klasse auf <html> setzen, ein paar CSS-Variablen umschalten, fertig. Das löst einen häufigen Anwendungsfall, verfehlt aber das interessantere Problem: Was, wenn Nutzer ihre eigene Markenfarbe wählen können? Was, wenn Ihr SaaS-Produkt mehrere Kunden mit je eigener Identität bedient? Was, wenn das Theme zur Laufzeit eine vollständige harmonische Palette aus einem einzigen Hex-Eingabewert generieren muss?

Da beginnt dynamisches Farb-Theming – und CSS-Variablen allein reichen nicht aus. Man braucht Farbalgorithmen zur Laufzeit, React-State, der die Navigation überlebt, Persistenzstrategien und eine Architektur, die den gesamten Komponentenbaum nicht bei jeder Slider-Bewegung neu rendert.

Dieser Leitfaden behandelt jede dieser Schichten ausführlich.


CSS-Variablen-Ansatz für React-Themes

Die Basis: Semantische Token-Architektur

Jedes dynamische Theme-System beginnt mit derselben Grundlage: CSS-Custom-Properties, die nach ihrer semantischen Rolle benannt sind, nicht nach ihrem Farbwert. Eine Variable nicht --blue-500 nennen, sondern --brand-primary. Der Wert kann sich ändern; die Rolle nicht:

/* globals.css */
:root {
  /* Semantische Token – diese verwenden die Komponenten */
  --bg-base: #F8FAFC;
  --bg-surface: #FFFFFF;
  --bg-sunken: #F1F5F9;

  --text-primary: #1E293B;
  --text-secondary: #64748B;
  --text-disabled: #94A3B8;

  --brand-primary: #2563EB;
  --brand-hover: #1D4ED8;
  --brand-subtle: #EFF6FF;
  --on-brand: #FFFFFF;

  --border-default: #E2E8F0;
  --border-strong: #CBD5E1;

  --status-error: #DC2626;
  --status-success: #16A34A;
  --status-warning: #D97706;
}

Komponenten referenzieren Token, niemals direkte Farben:

.card {
  background: var(--bg-surface);
  border: 1px solid var(--border-default);
  color: var(--text-primary);
}

.btn-primary {
  background: var(--brand-primary);
  color: var(--on-brand);
}

.btn-primary:hover {
  background: var(--brand-hover);
}

Wenn man --brand-primary in JavaScript aktualisiert, wird jede Komponente, die darauf verweist, sofort aktualisiert – ohne React-Re-Render, ohne Prop-Drilling, ohne Context-Subscriptions. CSS verwaltet die Kaskade.

Token aus JavaScript schreiben

Die DOM-API zum Setzen von Custom-Properties ist unkompliziert:

document.documentElement.style.setProperty('--brand-primary', '#7C3AED');
document.documentElement.style.setProperty('--brand-hover', '#6D28D9');
document.documentElement.style.setProperty('--brand-subtle', '#EDE9FE');
document.documentElement.style.setProperty('--on-brand', '#FFFFFF');

Und zum Zurücklesen:

const brand = getComputedStyle(document.documentElement)
  .getPropertyValue('--brand-primary')
  .trim();

Das ist die mechanische Grundlage. Das Schwierige ist, den vollständigen Token-Satz aus einem einzigen Marken-Hex zu generieren.


Laufzeit-Theme-Generierung mit Farbalgorithmen

Das Problem mit manuellen Token-Sätzen

Wenn Nutzer jede beliebige Farbe wählen können – einschließlich ungewöhnlicher wie #FF5733 oder #0D9488 – können Sie keine dunkle Variante, keinen Hover-Zustand oder keine On-Brand-Textfarbe hardcoden. Sie müssen sie aus dem Input berechnen.

Den vollständigen Token-Satz generieren

Hier ist eine vollständige Funktion, die einen Marken-Hex nimmt und alle benötigten Theme-Token erzeugt:

import chroma from 'chroma-js';

function generateThemeTokens(brandHex) {
  const brand = chroma(brandHex);

  // Bestimmen, ob On-Brand-Text schwarz oder weiß sein soll
  // basierend auf WCAG-Relativleuchtdichte
  const brandLuminance = brand.luminance();
  const onBrand = brandLuminance > 0.179 ? '#000000' : '#FFFFFF';

  // Eine Skala von hellem Farbton zu dunkler Schattierung in OKLCH generieren
  // für wahrnehmungsgleichmäßige Schritte
  const scale = chroma.scale([
    chroma(brandHex).brighten(2.5).desaturate(0.5).hex(),
    brandHex,
    chroma(brandHex).darken(2.5).hex(),
  ]).mode('oklch').colors(11);

  return {
    '--brand-primary': brandHex,
    '--brand-hover': chroma(brandHex).darken(0.5).hex(),
    '--brand-active': chroma(brandHex).darken(1).hex(),
    '--brand-subtle': scale[1],   // Sehr heller Farbton
    '--brand-muted': scale[2],    // Heller Farbton
    '--on-brand': onBrand,

    // Skalenprimitive für vollständigen Palettenzugang
    '--brand-50': scale[0],
    '--brand-100': scale[1],
    '--brand-200': scale[2],
    '--brand-300': scale[3],
    '--brand-400': scale[4],
    '--brand-500': scale[5],
    '--brand-600': scale[6],
    '--brand-700': scale[7],
    '--brand-800': scale[8],
    '--brand-900': scale[9],
    '--brand-950': scale[10],
  };
}

function applyTheme(brandHex) {
  const tokens = generateThemeTokens(brandHex);
  const root = document.documentElement;
  Object.entries(tokens).forEach(([prop, value]) => {
    root.style.setProperty(prop, value);
  });
}

Barrierefreie Farbpaare

Beim Generieren von Themes aus beliebigen Nutzereingaben kann Barrierefreiheit nicht vorausgesetzt werden. Eine vom Nutzer gewählte Farbe könnte schlechten Kontrast auf Weiß erzeugen. Überprüfen und anpassen:

function ensureContrast(foreground, background, minRatio = 4.5) {
  let color = chroma(foreground);
  let ratio = chroma.contrast(color.hex(), background);

  // Wenn der Kontrast unzureichend ist, den Vordergrund verdunkeln, bis er besteht
  let iterations = 0;
  while (ratio < minRatio && iterations < 20) {
    color = color.darken(0.2);
    ratio = chroma.contrast(color.hex(), background);
    iterations++;
  }

  return color.hex();
}

// Für eine Marken-farbige Textbeschriftung auf weißem Hintergrund
const safeLabel = ensureContrast('#FFD700', '#FFFFFF'); // Gelb angepasst, um AA zu bestehen

Dies anwenden, wenn Token für Textelemente generiert werden, die auf Weiß oder anderen festen Oberflächen erscheinen.

Komplementär- und Akzentfarben

Der Palettengenerator zeigt, wie eine einzelne Grundfarbe eine vollständige Harmoniefamilie erzeugt. Diese Logik kann im Code mit Farbtonrotation repliziert werden:

function generateComplementaryAccent(brandHex) {
  const hsl = chroma(brandHex).hsl();
  // Farbton um 180° drehen für Komplementär, Helligkeit verschieben für Balance
  const complementHue = (hsl[0] + 180) % 360;
  return chroma.hsl(complementHue, hsl[1] * 0.9, hsl[2]).hex();
}

function generateAnalogousAccent(brandHex, degrees = 30) {
  const hsl = chroma(brandHex).hsl();
  const analogousHue = (hsl[0] + degrees) % 360;
  return chroma.hsl(analogousHue, hsl[1], hsl[2]).hex();
}

React Context für Theme-State-Management

ThemeContext-Architektur

Der Theme-Context dient als Brücke zwischen Nutzerinteraktionen (Farbe wählen, Dark Mode umschalten) und der DOM-Token-Anwendung. Er sollte schlank sein: State speichern, ihn auf das DOM anwenden und einen Setter bereitstellen. Farbberechnungen finden außerhalb des Contexts statt:

// contexts/ThemeContext.tsx
import {
  createContext,
  useContext,
  useEffect,
  useState,
  useCallback,
  type ReactNode,
} from 'react';
import { generateThemeTokens } from '../lib/theme-generator';

interface ThemeState {
  brandHex: string;
  mode: 'light' | 'dark' | 'system';
}

interface ThemeContextValue extends ThemeState {
  setBrand: (hex: string) => void;
  setMode: (mode: 'light' | 'dark' | 'system') => void;
  resolvedMode: 'light' | 'dark';
}

const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);

const STORAGE_KEY = 'app-theme';

function getStoredTheme(): ThemeState {
  try {
    const stored = localStorage.getItem(STORAGE_KEY);
    if (stored) return JSON.parse(stored) as ThemeState;
  } catch {}
  return { brandHex: '#2563EB', mode: 'system' };
}

export function ThemeProvider({ children }: { children: ReactNode }) {
  const [state, setState] = useState<ThemeState>(getStoredTheme);

  const systemDark =
    typeof window !== 'undefined'
      ? window.matchMedia('(prefers-color-scheme: dark)').matches
      : false;

  const resolvedMode: 'light' | 'dark' =
    state.mode === 'system'
      ? systemDark ? 'dark' : 'light'
      : state.mode;

  // CSS-Token anwenden, wenn sich der State ändert
  useEffect(() => {
    const tokens = generateThemeTokens(state.brandHex);
    const root = document.documentElement;
    Object.entries(tokens).forEach(([prop, value]) => {
      root.style.setProperty(prop, value as string);
    });
    root.setAttribute('data-theme', resolvedMode);
    localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
  }, [state, resolvedMode]);

  // Auf Systemeinstellungsänderungen reagieren
  useEffect(() => {
    if (state.mode !== 'system') return;
    const media = window.matchMedia('(prefers-color-scheme: dark)');
    const handler = (e: MediaQueryListEvent) => {
      document.documentElement.setAttribute(
        'data-theme',
        e.matches ? 'dark' : 'light',
      );
    };
    media.addEventListener('change', handler);
    return () => media.removeEventListener('change', handler);
  }, [state.mode]);

  const setBrand = useCallback((hex: string) => {
    setState(prev => ({ ...prev, brandHex: hex }));
  }, []);

  const setMode = useCallback((mode: 'light' | 'dark' | 'system') => {
    setState(prev => ({ ...prev, mode }));
  }, []);

  return (
    <ThemeContext.Provider value={{ ...state, setBrand, setMode, resolvedMode }}>
      {children}
    </ThemeContext.Provider>
  );
}

export function useTheme(): ThemeContextValue {
  const ctx = useContext(ThemeContext);
  if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
  return ctx;
}

Die Farbauswahl-Komponente

Mit dem Context an Ort und Stelle wird ein Farbwähler zu einer schlanken UI-Schicht:

// components/BrandColorPicker.tsx
import { useTheme } from '../contexts/ThemeContext';
import { useState, useRef } from 'react';

export function BrandColorPicker() {
  const { brandHex, setBrand } = useTheme();
  const [localHex, setLocalHex] = useState(brandHex);
  const debounceRef = useRef<ReturnType<typeof setTimeout>>();

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const value = e.target.value;
    setLocalHex(value);

    // Die aufwändige Token-Generierung entprellen
    clearTimeout(debounceRef.current);
    debounceRef.current = setTimeout(() => {
      if (/^#[0-9A-Fa-f]{6}$/.test(value)) {
        setBrand(value);
      }
    }, 150);
  };

  return (
    <div className="flex items-center gap-3">
      <label htmlFor="brand-picker" className="text-sm font-medium">
        Markenfarbe
      </label>
      <div className="relative">
        <input
          id="brand-picker"
          type="color"
          value={localHex}
          onChange={handleChange}
          className="sr-only"
        />
        <label
          htmlFor="brand-picker"
          className="block w-8 h-8 rounded-md cursor-pointer ring-2 ring-offset-2"
          style={{
            backgroundColor: localHex,
            // Ring-Farbe verwendet den Brand-Token
            '--tw-ring-color': 'var(--brand-primary)',
          } as React.CSSProperties}
          aria-label={`Aktuelle Markenfarbe: ${localHex}`}
        />
      </div>
      <input
        type="text"
        value={localHex}
        onChange={handleChange}
        className="w-24 px-2 py-1 text-sm font-mono border rounded"
        placeholder="#2563EB"
        aria-label="Markenfarbe Hex-Wert"
      />
    </div>
  );
}

Das 150-ms-Entprellen ist entscheidend – ohne es läuft generateThemeTokens bei jedem Tastendruck beim Tippen im Hex-Eingabefeld, was unnötig teuer ist.


Tailwind CSS dynamische Farbklassen

Die Herausforderung mit Tailwind und Laufzeitfarben

Tailwind generiert Utility-Klassen zur Build-Zeit. Klassen wie bg-brand-500 existieren in der Stylesheet nur, wenn Tailwinds Scanner sie in den Quelldateien gefunden hat. Eine zur Laufzeit bestimmte Farbe – beispielsweise die, die der Nutzer gerade ausgewählt hat – wird nicht im Stylesheet sein.

Es gibt zwei Lösungen, die unterschiedlichen Bedürfnissen dienen:

Lösung 1: CSS-Variablen-Bridge

Der sauberste Ansatz besteht darin, eine kleine Menge von Tailwind-Theme-Token zu definieren, die auf CSS-Custom-Properties zeigen, und dann die Werte dieser Properties zur Laufzeit zu steuern:

/* styles.css (Tailwind v4) */
@import "tailwindcss";

@theme {
  --color-brand: var(--brand-primary);
  --color-brand-hover: var(--brand-hover);
  --color-brand-subtle: var(--brand-subtle);
  --color-on-brand: var(--on-brand);
}

Jetzt sind bg-brand, text-brand und hover:bg-brand-hover gültige Tailwind-Klassen, die den jeweils aktuellen Wert von --brand-primary zur Laufzeit widerspiegeln:

<button className="bg-brand text-on-brand hover:bg-brand-hover px-4 py-2 rounded-lg">
  Primäre Aktion
</button>

Das Ändern von --brand-primary über JavaScript aktualisiert sofort jedes Element mit bg-brand – kein Re-Render erforderlich.

Lösung 2: Inline-Styles für einmalige dynamische Farben

Wenn eine Komponente eine wirklich einmalige Laufzeitfarbe benötigt (zum Beispiel ein Farbmuster in einem Palettenviewer), verwenden Sie einen Inline-Style. Tailwinds beliebige Wertsyntax bg-[#FF5733] funktioniert für statische, zur Build-Zeit bekannte Werte, aber nicht für bg-[${dynamicHex}] in einem Template-Literal in JSX – die Klasse existiert nicht im Stylesheet.

// Korrekt für wirklich dynamische Farben:
<div
  style={{ backgroundColor: swatch.hex }}
  className="w-8 h-8 rounded"
  aria-label={swatch.name}
/>

// Das funktioniert NICHT zur Laufzeit (Klasse nicht im Stylesheet):
<div className={`bg-[${swatch.hex}]`} /> // Falsch

Nutzerfarbpräferenzen persistieren

localStorage für sitzungsübergreifende Persistenz

Der ThemeContext oben schreibt bereits im useEffect in localStorage. Das entscheidende Muster für SSR-Kompatibilität ist die verzögerte Initialisierung – eine Funktion an useState übergeben, die localStorage nur auf dem Client liest, nie während des Server-Renderings:

const [state, setState] = useState<ThemeState>(() => {
  // Diese Funktion läuft nur auf dem Client, nach der Hydration
  if (typeof window === 'undefined') {
    return { brandHex: '#2563EB', mode: 'system' };
  }
  try {
    const stored = localStorage.getItem(STORAGE_KEY);
    return stored ? JSON.parse(stored) : { brandHex: '#2563EB', mode: 'system' };
  } catch {
    return { brandHex: '#2563EB', mode: 'system' };
  }
});

Flash des Standard-Themes beim Laden verhindern

Bei server-gerenderten Apps (Next.js App Router) rendert der Komponentenbaum auf dem Server, bevor er den Browser erreicht. Wenn mit dem Standard-Theme initialisiert wird, sehen Nutzer, die ein benutzerdefiniertes Theme gespeichert haben, einen Flash – das Standard-Blau erscheint kurz, bevor das gespeicherte Lila geladen wird.

Die Lösung ist ein blockierendes Inline-Script im <head>, das das gespeicherte Theme anwendet, bevor der Browser Pixel rendert:

// app/layout.tsx (Next.js App Router)
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="de" suppressHydrationWarning>
      <head>
        <script
          dangerouslySetInnerHTML={{
            __html: `
              (function() {
                try {
                  var stored = JSON.parse(localStorage.getItem('app-theme') || '{}');
                  var brand = stored.brandHex || '#2563EB';
                  var mode = stored.mode || 'system';
                  var resolvedMode = mode === 'system'
                    ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
                    : mode;
                  document.documentElement.setAttribute('data-theme', resolvedMode);
                  // Minimale Inline-Token, um Flash zu verhindern
                  document.documentElement.style.setProperty('--brand-primary', brand);
                } catch(e) {}
              })();
            `,
          }}
        />
      </head>
      <body>
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  );
}

Das Inline-Script wendet nur die kritischsten Token (Markenfarbe, Theme-Mode-Attribut) synchron an. Der vollständige Token-Satz wird nach der Hydration vom ThemeProvider angewendet – da die dominanten visuellen Token aber bereits korrekt sind, sehen Nutzer keinen Flash.

Serverseitige Persistenz über Cookies

Für authentifizierte Apps ermöglicht das Speichern des Themes in einem Cookie (vom Server lesbar) SSR mit dem korrekten Theme vom ersten Byte an:

// middleware.ts (Next.js)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  const brandHex = request.cookies.get('brand-hex')?.value ?? '#2563EB';
  const mode = request.cookies.get('theme-mode')?.value ?? 'system';

  // Theme über Response-Header oder Suchparameter an die Seite übergeben
  const response = NextResponse.next();
  response.headers.set('x-brand-hex', brandHex);
  response.headers.set('x-theme-mode', mode);
  return response;
}

Die Header im Root-layout.tsx-Server-Component lesen und das initiale Theme als Prop an den ThemeProvider übergeben. Der Provider initialisiert mit dem server-bekannten Theme, was den Flash vollständig eliminiert.


Barrierefreie Dark-Mode-Varianten generieren

Wenn ein Hellfarbtheme aus einer Markenfarbe generiert wird, braucht man auch eine Dark-Mode-Version. Die Herausforderung: Derselbe Marken-Hex, der auf einem hellen Hintergrund gut funktioniert – zum Beispiel #2563EB – erfüllt häufig nicht die WCAG-Kontrastanforderungen auf dunklen Oberflächen.

Ein robuster Ansatz generiert zwei Token-Sätze – einen für Hell, einen für Dunkel – aus demselben Marken-Input:

function generateDarkTokens(brandHex) {
  const brand = chroma(brandHex);
  const hsl = brand.hsl();

  // Im Dark Mode die Marke aufhellen, um Kontrast auf dunklen Oberflächen zu gewährleisten
  const darkBrand = chroma.hsl(hsl[0], hsl[1] * 0.9, Math.max(0.55, hsl[2] + 0.15));

  return {
    '[data-theme="dark"]': {
      '--brand-primary': darkBrand.hex(),
      '--brand-hover': darkBrand.lighten(0.3).hex(),
      '--brand-subtle': darkBrand.darken(2).desaturate(0.5).hex(),
      '--on-brand': '#000000', // Im Dark Mode oft schwarz auf aufgehellter Marke
      '--bg-base': '#0F172A',
      '--bg-surface': '#1E293B',
      '--text-primary': '#F1F5F9',
      '--text-secondary': '#94A3B8',
    },
  };
}

Dunkle Token über das data-theme="dark"-Attributmuster anwenden. Wenn der ThemeProvider document.documentElement.setAttribute('data-theme', 'dark') setzt, wendet die CSS-Kaskade die Dark-Mode-Token-Werte an.

Um zu überprüfen, ob ein generiertes Farbpaar WCAG besteht, den Palettengenerator verwenden, um die vollständige Skala zu visualisieren und Kontrastverhältnisse zwischen Stufen zu überprüfen.


Wichtigste Erkenntnisse

  • CSS-Custom-Properties sind die Grundlage – semantische Token, die nach Rolle statt Farbwert benannt sind, ermöglichen vollständige Theme-Änderungen ohne Modifizierung von Komponenten.
  • Laufzeit-Token-Generierung aus einem einzigen Marken-Hex erfordert: eine Helligkeits-/Dunkelheitsskala, WCAG-Kontrastprüfung für On-Brand-Text und separate Berechnung für Dark-Mode-Varianten.
  • React-Context verwaltet den Theme-State (aktiver Marken-Hex, Hell-/Dunkel-Modus) und wendet Token als Nebeneffekt auf das DOM an – Komponenten müssen sich niemals beim Theme-Context anmelden, um Farben anzuwenden.
  • Aufwändige Farbberechnungen entprellen (150 ms), wenn sie mit kontinuierlichen Eingaben wie Farbwählern oder Slidern verbunden sind.
  • Für Tailwind CSS: eine CSS-Variablen-Bridge in @theme definieren, damit bg-brand-Utilities den Laufzeit-Token-Wert widerspiegeln; Inline-Styles nur für wirklich einmalige, pro-Element dynamische Farben verwenden.
  • Flash des Standard-Themes mit einem synchronen blockierenden Script in <head> verhindern, das die kritischsten Token vor dem Render anwendet.
  • Für SSR-Apps mit nutzerspezifischen Themes das Theme in einem Cookie speichern, damit der Server das korrekte initiale HTML rendern kann.
  • Den Farbton-Generator verwenden, um generierte Farbskalen gegenüber dem vollständigen 50–950-Bereich zu inspizieren und zu validieren, und den Palettengenerator, um Harmonie und Kontrastverhältnisse über die gesamte Theme-Palette zu überprüfen.

Ähnliche Farben

Ähnliche Marken

Ähnliche Werkzeuge