GitHub

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:

01

User clicks toggle

ThemeToggle calls toggleTheme()

02

Zustand updates

theme state flips light ↔ dark

03

useEffect fires

classList.add/remove('dark') on <html>

04

Tailwind activates

All dark: variants become active

05

localStorage saved

Preference persists across sessions

stores/useAppStore.ts
// 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
// 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>
  );
}
globals.css — CSS variable switch
/* 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.

Dark mode in components
// ✅ 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"
)} />
PurposeLightDark
Page backgroundbg-background-lightdark:bg-background-dark
Card / panelbg-whitedark:bg-[#161b22]
Inner surfacebg-slate-50dark:bg-[#0d1117]
Borderborder-slate-200dark:border-[#1f2937]
Primary texttext-slate-900dark:text-white
Secondary texttext-slate-500dark:text-slate-400
Muted texttext-slate-400dark:text-slate-500
Dividerdivide-slate-100dark: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 pattern
// ✅ 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.

react-principles