← Zurueck zum Blog
next-jstailwinddesign-systemcssnext-themesdark-modelight-mode

Dark Mode war gestern — Light Mode für macip.de mit next-themes und CSS Custom Properties

Marco Carstensen·8. März 2026·6 Min. Lesezeit

Dark Mode war gestern — Light Mode für macip.de

macip.de war von Anfang an ein Dark-Mode-Projekt. Jede Farbe, jeder Token, jeder Kontrast — alles auf dunklen Hintergrund abgestimmt. Aber nicht jeder arbeitet gerne im Dunkeln. Zeit für einen Light Mode.

Das Ziel: Ein vollstaendiges Theme-System mit Toggle-Schalter, das in beiden Modi visuell sauber funktioniert — ohne bestehende Komponenten zu zerstoeren.


Die Herausforderung

macip.de nutzt Tailwind CSS v4 mit einem eigenen Design-Token-System. Über 20 Farb-Tokens steuern alles: Surfaces, Borders, Text-Hierarchie, Akzent-Farben. Das Problem: Alle Tokens waren als statische Hex-Werte direkt in @theme inline definiert.

/* Vorher: Statische Hex-Werte */
@theme inline {
  --color-surface: #080810;
  --color-surface-card: #0f0f1a;
  --color-text-heading: #eeeef8;
  --color-accent-teal: #00d4aa;
}

Das bedeutet: Tailwind-Klassen wie bg-surface oder text-text-heading verweisen immer auf denselben Wert. Kein Raum fuer Themes.


Der Ansatz: CSS-Variablen-Swap via .dark Klasse

Der Kern der Loesung: Eine Indirektionsschicht zwischen Tailwind-Tokens und den eigentlichen Farbwerten.

Schritt 1: Alle macip-Farben werden als CSS Custom Properties definiert — einmal fuer Light (:root), einmal fuer Dark (.dark).

Schritt 2: @theme inline referenziert diese Variablen statt fester Hex-Werte.

/* Nachher: CSS-Variablen mit Theme-Support */
:root {
  --macip-surface: #f5f5fa;        /* Light */
  --macip-surface-card: #ffffff;
  --macip-text-heading: #1a1a2e;
  --macip-accent-teal: #00a88a;
}
 
.dark {
  --macip-surface: #080810;        /* Dark (unveraendert) */
  --macip-surface-card: #0f0f1a;
  --macip-text-heading: #eeeef8;
  --macip-accent-teal: #00d4aa;
}
 
@theme inline {
  --color-surface: var(--macip-surface);
  --color-surface-card: var(--macip-surface-card);
  --color-text-heading: var(--macip-text-heading);
  --color-accent-teal: var(--macip-accent-teal);
}

Das Ergebnis: Alle bestehenden Tailwind-Klassen (bg-surface, text-text-heading, border-border-subtle) schalten automatisch um — ohne eine einzige Aenderung in den meisten Komponenten.


next-themes: Toggle ohne FOUC

next-themes ist die Standard-Loesung fuer Theme-Toggling in Next.js. Es setzt eine .dark Klasse auf <html>, persistiert die Wahl in localStorage und verhindert Flash of Unstyled Content (FOUC) durch ein Blocking-Script.

Der ThemeProvider-Wrapper:

import { ThemeProvider as NextThemesProvider } from "next-themes";
 
export function ThemeProvider({ children }: { children: React.ReactNode }) {
  return (
    <NextThemesProvider
      attribute="class"
      defaultTheme="dark"
      enableSystem
      disableTransitionOnChange
    >
      {children}
    </NextThemesProvider>
  );
}

Wichtige Einstellungen:

  • attribute="class" — setzt .dark / .light auf <html>
  • defaultTheme="dark" — Standard bleibt Dark Mode
  • enableSystem — respektiert prefers-color-scheme
  • disableTransitionOnChange — verhindert Flash-Animationen beim Toggle

Im Root Layout braucht <html> ein suppressHydrationWarning, weil next-themes die Klasse vor React-Hydration setzt.


Der ThemeToggle

Eine kompakte Client Component mit Sonne/Mond-Icons:

export function ThemeToggle() {
  const { resolvedTheme, setTheme } = useTheme();
  const [mounted, setMounted] = useState(false);
 
  useEffect(() => setMounted(true), []);
 
  if (!mounted) return <div className="h-8 w-8" />;
 
  const isDark = resolvedTheme === "dark";
  return (
    <button onClick={() => setTheme(isDark ? "light" : "dark")}>
      {isDark ? <SunIcon /> : <MoonIcon />}
    </button>
  );
}

Der mounted-Check ist entscheidend: Waehrend der Server-Side-Rendering-Phase kennt React das aktuelle Theme nicht. Ohne den Check wuerde das falsche Icon gerendert und beim Hydrating geflasht.

Der Toggle wurde eingebaut in:

  • Navbar (Desktop: zwischen Nav-Links und CTA, Mobile: im Hamburger-Menu)
  • Dashboard-Header (zwischen User-Email und Logout)

Die Light-Mode-Farbpalette

Einfach die Dark-Mode-Farben invertieren reicht nicht. Jede Farbe braucht eine bewusste Light-Variante:

TokenDarkLightWarum
surface#080810#f5f5faHelles, leicht blaeuliches Grau statt reinem Weiss
surface-card#0f0f1a#ffffffKarten heben sich durch reines Weiss ab
accent-teal#00d4aa#00a88aDunkler fuer ausreichend Kontrast auf hellem Hintergrund
text-heading#eeeef8#1a1a2eNahezu Schwarz, hoher Kontrast
text-body#c0c0d4#3a3a58Dunkles Grau, gut lesbar

Die Akzent-Farben (Teal, Blue, Purple) sind im Light Mode etwas dunkler — sie muessen auf hellem Hintergrund genauso gut funktionieren wie auf dunklem.


Das groesste Problem: Hardcoded Hex-Werte

Trotz des Token-Systems hatten sich ueber die Zeit Hex-Werte in Inline-Styles und Tailwind-Arbitrary-Values eingeschlichen:

// Vorher: Funktioniert nur im Dark Mode
const ACCENT = "#5b8fff";
<span style={{ color: ACCENT }}>Tag</span>
 
// Nachher: Funktioniert in beiden Modi
const ACCENT = "var(--macip-accent-blue)";
<span style={{ color: ACCENT }}>Tag</span>

Ein besonderes Problem waren Opacity-Suffixe bei Hex-Werten. Tailwind erlaubt bg-accent-teal/10 fuer 10% Opacity, aber var(--macip-accent-teal)/10 funktioniert nicht in Inline-Styles. Die Loesung: color-mix().

/* Vorher: Hex mit Opacity-Suffix */
background: #5b8fff14;  /* Blue mit ~8% Opacity */
 
/* Nachher: color-mix() mit CSS-Variable */
background: color-mix(in srgb, var(--macip-accent-blue) 8%, transparent);

color-mix() ist ein modernes CSS-Feature (Baseline 2023) und funktioniert in allen aktuellen Browsern.


CTA-Button-Kontrast: Der on-accent Token

Ein subtiles Problem: Die CTA-Buttons nutzen einen Teal-Gradient als Hintergrund. Der Text war text-surface (also die Seitenhintergrund-Farbe). Im Dark Mode: Dunkler Text auf hellem Button — funktioniert. Im Light Mode: Heller Text auf hellem Button — unsichtbar.

Die Loesung: Ein neuer Token on-accent, der immer kontrastreich zum Akzent-Hintergrund ist:

:root  { --macip-on-accent: #ffffff; }  /* Weiss auf farbigem Button */
.dark  { --macip-on-accent: #080810; }  /* Dunkel auf farbigem Button */

Alle CTAs verwenden jetzt text-on-accent statt text-surface.


Umfang der Aenderungen

2 neue Dateien:

  • ThemeProvider.tsx — next-themes Wrapper
  • ThemeToggle.tsx — Sonne/Mond-Toggle

17 modifizierte Dateien:

  • globals.css — Kompletter Token-Umbau (Kern der Arbeit)
  • layout.tsx — ThemeProvider + suppressHydrationWarning
  • Navbar.tsx, DashboardLayout.tsx — Toggle einbauen
  • 10+ Komponenten — Hardcoded Hex durch CSS-Variablen ersetzen
  • projects.ts — Neues ACCENT_CSS_VAR Map

Der Blog-Editor (BlogEditorForm.tsx) war die aufwaendigste Einzeldatei: 10+ Hex-Klassen in generierten HTML-Strings mussten durch semantische Token-Klassen ersetzt werden.


Verifizierung

Nach der Migration wurde jede Seite in beiden Modi geprueft:

  • Homepage mit Hero, Infrastruktur-Kacheln, Projekt-Karten, Blog-Teasern
  • Blog-Uebersicht mit Filter-Tags
  • Blog-Detail mit Code-Bloecken
  • Kontaktformular
  • Projekt-Detail mit Features und Stack
  • Mobile Hamburger-Menu mit Toggle
  • Dashboard mit Blog-Editor

TypeScript und ESLint: Keine Fehler.


Fazit

Der Umbau zeigt: Ein gut strukturiertes Token-System zahlt sich aus. Weil macip.de konsequent Design-Tokens statt roher Farben nutzt, konnte der Light Mode mit einer einzigen CSS-Datei als Kern-Aenderung umgesetzt werden. Die meisten Komponenten brauchten keine Anpassung.

Die groesste Arbeit war nicht das Theme-System selbst, sondern das Aufspueren und Ersetzen von Hex-Werten, die sich trotz der Token-Konvention eingeschlichen hatten. Eine gute Erinnerung: Design-Token-Disziplin ist keine Pedanterie — sie ist die Voraussetzung fuer Theme-Faehigkeit.


Stack

  • next-themes — Theme-Toggle, localStorage-Persistenz, FOUC-Praevention
  • CSS Custom Properties:root / .dark Variablen-Swap
  • Tailwind CSS v4@theme inline mit var() Referenzen
  • color-mix() — Theme-aware Opacity ohne Hex-Suffixe