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/.lightauf<html>defaultTheme="dark"— Standard bleibt Dark ModeenableSystem— respektiertprefers-color-schemedisableTransitionOnChange— 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:
| Token | Dark | Light | Warum |
|---|---|---|---|
| surface | #080810 | #f5f5fa | Helles, leicht blaeuliches Grau statt reinem Weiss |
| surface-card | #0f0f1a | #ffffff | Karten heben sich durch reines Weiss ab |
| accent-teal | #00d4aa | #00a88a | Dunkler fuer ausreichend Kontrast auf hellem Hintergrund |
| text-heading | #eeeef8 | #1a1a2e | Nahezu Schwarz, hoher Kontrast |
| text-body | #c0c0d4 | #3a3a58 | Dunkles 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 WrapperThemeToggle.tsx— Sonne/Mond-Toggle
17 modifizierte Dateien:
globals.css— Kompletter Token-Umbau (Kern der Arbeit)layout.tsx— ThemeProvider + suppressHydrationWarningNavbar.tsx,DashboardLayout.tsx— Toggle einbauen- 10+ Komponenten — Hardcoded Hex durch CSS-Variablen ersetzen
projects.ts— NeuesACCENT_CSS_VARMap
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/.darkVariablen-Swap - Tailwind CSS v4 —
@theme inlinemitvar()Referenzen - color-mix() — Theme-aware Opacity ohne Hex-Suffixe