Dark Mode
Dark mode is managed by a Zustand store, persisted to localStorage, and applied via Tailwind's class strategy. The toggle button in the navbar handles everything.
How It Works
Five steps from click to render:
User clicks toggle
ThemeToggle calls toggleTheme()
Zustand updates
theme state flips light ↔ dark
useEffect fires
classList.add/remove('dark') on <html>
Tailwind activates
All dark: variants become active
localStorage saved
Preference persists across sessions
// stores/useAppStore.ts type Theme = "light" | "dark"; export const useAppStore = create<AppState>((set) => ({ theme: "dark", toggleTheme: () => set((state) => ({ theme: state.theme === "light" ? "dark" : "light", })), setTheme: (theme) => set({ theme }), // ... }));
ThemeToggle
The ThemeToggle component lives in the navbar. It bridges Zustand state to the DOM and handles initialization on mount.
// src/features/landing/components/ThemeToggle.tsx export function ThemeToggle() { const { theme, toggleTheme } = useAppStore(); // Sync state → <html> class + localStorage useEffect(() => { const root = document.documentElement; theme === "dark" ? root.classList.add("dark") : root.classList.remove("dark"); localStorage.setItem("theme", theme); }, [theme]); // Init: read localStorage → fallback to system preference useEffect(() => { const stored = localStorage.getItem("theme"); if (stored === "dark" || stored === "light") { useAppStore.getState().setTheme(stored); } else if (window.matchMedia("(prefers-color-scheme: dark)").matches) { useAppStore.getState().setTheme("dark"); } }, []); return ( <button onClick={toggleTheme} aria-label="Toggle theme"> <span className="material-symbols-outlined"> {theme === "dark" ? "light_mode" : "dark_mode"} </span> </button> ); }
/* src/app/globals.css */ :root { --background: #f6f6f8; --foreground: #0f172a; } .dark { --background: #020617; --foreground: #e2e8f0; } body { color: var(--foreground); background: var(--background); }
System Preference
On first visit (no localStorage entry), the app reads the OS preference viaprefers-color-scheme. Subsequent visits use the stored value.
First visit, OS = dark
Reads prefers-color-scheme: dark → sets theme to dark
First visit, OS = light
prefers-color-scheme: light → default light theme
Return visit
localStorage["theme"] overrides everything
Writing Dark Styles
Add dark: variants alongside the light class. Use cn() from the shared utils for conditional merging.
// ✅ Standard dark mode — use dark: prefix <div className="bg-white dark:bg-[#161b22]"> <h2 className="text-slate-900 dark:text-white">Title</h2> <p className="text-slate-500 dark:text-slate-400">Subtitle</p> <div className="border-slate-200 dark:border-[#1f2937]" /> </div> // ✅ Conditional with cn() import { cn } from "@/shared/utils/cn"; <div className={cn( "rounded-lg border p-4", "bg-white dark:bg-[#161b22]", "border-slate-200 dark:border-[#1f2937]", isActive && "ring-2 ring-primary" )} />
| Purpose | Light | Dark |
|---|---|---|
| Page background | bg-background-light | dark:bg-background-dark |
| Card / panel | bg-white | dark:bg-[#161b22] |
| Inner surface | bg-slate-50 | dark:bg-[#0d1117] |
| Border | border-slate-200 | dark:border-[#1f2937] |
| Primary text | text-slate-900 | dark:text-white |
| Secondary text | text-slate-500 | dark:text-slate-400 |
| Muted text | text-slate-400 | dark:text-slate-500 |
| Divider | divide-slate-100 | dark:divide-[#1f2937] |
Gotchas
Two patterns that can cause confusion.
Forced-theme previews
The "Theme Preview" section on every component docs page renders both light and dark appearances simultaneously — regardless of the app's current theme. This requires hardcoded classes only, no dark: prefix.
// ✅ Forced-theme preview (no dark: prefix) // Used in docs "Theme Preview" sections to show both themes side-by-side // regardless of the app's current theme setting. function ThemedPreview({ theme }: { theme: "light" | "dark" }) { const bg = theme === "dark" ? "bg-[#161b22]" : "bg-white"; const text = theme === "dark" ? "text-white" : "text-slate-900"; return ( // No dark: prefix here — hardcoded classes only <div className={`${bg} rounded-xl p-4`}> <p className={text}>Always renders as specified theme</p> </div> ); }
Theme flash on hard reload
Since the theme is initialised in a React useEffect, there may be a brief flash on first paint if the stored preference is dark but the initial HTML renders with light styles. A common fix is to inline a small script in <head> that reads localStorage before React hydrates.
This project doesn't implement the anti-flash script — it's a reference implementation, not a production app. For production, add a blocking script in <head> that checks localStorage and sets the class before render.