Let’s make theme changes feel instant and “just work” across the app.

Below is a copy-paste setup that:

Applies light/dark/system immediately (no reload)

Persists to server via /api/user/settings

Follows OS theme when set to system (and updates live if OS flips)

Uses Tailwind’s darkMode: 'class'

0) Tailwind config (one-time)

Make sure your tailwind.config.js has:

module.exports = {
  darkMode: 'class',
  // ...
}

1) Theme utility (apply + observe)

src/theme/applyTheme.ts

type Theme = 'light' | 'dark' | 'system';

let mediaQuery: MediaQueryList | null = null;
let onSystemChange: ((isDark: boolean) => void) | null = null;

function setHtmlClass(dark: boolean) {
  const el = document.documentElement;
  if (dark) el.classList.add('dark'); else el.classList.remove('dark');
}

export function getSystemPrefersDark() {
  return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
}

export function applyTheme(theme: Theme) {
  // remove existing listener
  if (mediaQuery && onSystemChange) {
    mediaQuery.removeEventListener('change', onSystemChange as any);
    mediaQuery = null;
    onSystemChange = null;
  }

  if (theme === 'system') {
    const isDark = getSystemPrefersDark();
    setHtmlClass(isDark);
    mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
    onSystemChange = (e: MediaQueryListEvent | boolean) => {
      const dark = typeof e === 'boolean' ? e : e.matches;
      setHtmlClass(dark);
    };
    mediaQuery.addEventListener('change', onSystemChange as any);
  } else {
    setHtmlClass(theme === 'dark');
  }

  // keep latest in localStorage for first paint
  try { localStorage.setItem('theme', theme); } catch {}
}

/** Initialize ASAP on app boot for no-FoUC */
export function initThemeEarly() {
  // 1) local override
  let theme = (localStorage.getItem('theme') as Theme) || 'system';
  // 2) apply immediately (before fetching server)
  applyTheme(theme);
}

2) Initialize early (prevents flash)

In your root entry (whichever runs before rendering):

src/main.tsx (or src/index.tsx)

import { initThemeEarly } from '@/theme/applyTheme';
initThemeEarly();

// then mount your app...


If you have an HTML entry, you can even inline a guard, but initThemeEarly() is enough for most.

3) Theme context/provider (sync with server)

src/theme/ThemeProvider.tsx

import { createContext, useContext, useEffect, useState } from 'react';
import { applyTheme } from './applyTheme';

type Theme = 'light' | 'dark' | 'system';
type SettingsDTO = { theme: Theme; timezone?: string; notifications?: any };

type Ctx = {
  theme: Theme;
  setTheme: (t: Theme, persist?: boolean) => void;
};

const ThemeCtx = createContext<Ctx>({ theme: 'system', setTheme: () => {} });
export const useTheme = () => useContext(ThemeCtx);

export default function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setThemeState] = useState<Theme>('system');

  // Load from server once user is known (or anonymous fallback)
  useEffect(() => {
    (async () => {
      try {
        const res = await fetch('/api/user/settings', { credentials: 'include' });
        if (res.ok) {
          const data: SettingsDTO = await res.json();
          const t = (data?.theme || 'system') as Theme;
          setThemeState(t);
          applyTheme(t);
          return;
        }
      } catch {}
      // fallback to localStorage if server not available
      const local = (localStorage.getItem('theme') as Theme) || 'system';
      setThemeState(local);
      applyTheme(local);
    })();
  }, []);

  const setTheme = async (t: Theme, persist = true) => {
    setThemeState(t);
    applyTheme(t);
    if (persist) {
      try {
        await fetch('/api/user/settings', {
          method: 'POST',
          credentials: 'include',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ theme: t }),
        });
      } catch {}
    }
  };

  return <ThemeCtx.Provider value={{ theme, setTheme }}>{children}</ThemeCtx.Provider>;
}


Wrap your app:

src/main.tsx

import ThemeProvider from '@/theme/ThemeProvider';
import NotificationsProvider from '@/notifications/NotificationsProvider';
import { Toaster } from 'sonner';

// ...
root.render(
  <ThemeProvider>
    <NotificationsProvider>
      <RouterProvider router={router} />
      <Toaster position="top-right" />
    </NotificationsProvider>
  </ThemeProvider>
);

4) Hook Settings UI to live theme

Update the Preferences tab in your SettingsPage.tsx to call setTheme:

// inside SettingsPage.tsx
import { useTheme } from '@/theme/ThemeProvider';

// ...
const { theme, setTheme } = useTheme();

// replace your Theme select to use context + keep saving to server via save()
<select
  className="border rounded-xl px-3 py-2 mb-4"
  value={theme}
  onChange={(e) => {
    const t = e.target.value as 'light' | 'dark' | 'system';
    setTheme(t);                  // instant apply + persists via provider
    save({ theme: t });           // (optional; provider already POSTs, this keeps your page state in sync if you also show saving state)
  }}
>
  <option value="system">System</option>
  <option value="light">Light</option>
  <option value="dark">Dark</option>
</select>


You can keep your existing save() if you want to show a “Saving…” indicator; the provider already persists, so either approach is fine.

5) Optional: theme switcher in header

Add a tiny quick-switch in your header for power users:

// src/components/ThemeSwitch.tsx
import { useTheme } from '@/theme/ThemeProvider';

export default function ThemeSwitch() {
  const { theme, setTheme } = useTheme();
  const next = theme === 'light' ? 'dark' : theme === 'dark' ? 'system' : 'light';
  const label = { light: 'Light', dark: 'Dark', system: 'System' }[theme];

  return (
    <button
      title={`Theme: ${label} (click to cycle)`}
      className="px-3 py-2 text-sm rounded-xl border hover:bg-gray-50 dark:hover:bg-gray-800"
      onClick={() => setTheme(next)}
    >
      {label}
    </button>
  );
}


Drop it near the gear or user avatar if you like.

Prompt for Replit (single message)
Add Tailwind dark mode class strategy and live theme switching.

1) Confirm tailwind.config.js has: darkMode: 'class'.
2) Create src/theme/applyTheme.ts and src/theme/ThemeProvider.tsx from the provided code.
3) Call initThemeEarly() at the very top of src/main.tsx before rendering.
4) Wrap the app with <ThemeProvider> (outside NotificationsProvider/Router).
5) Update SettingsPage Preferences → Theme select to use useTheme().setTheme for instant apply.
6) (Optional) Add ThemeSwitch button to header.