Dark/light theme switching#

A theme toggle that persists to localStorage, syncs across components via Palmyra’s topic pub-sub, and allows specific routes (e.g. payment pages) to force a mode.

Theme context#

// src/wire/ThemeProvider.tsx
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
import { topic } from '@palmyralabs/ts-utils';
import { useLocation } from 'react-router-dom';

type Theme = 'light' | 'dark';
const STORAGE_KEY = 'palmyra.theme';

// Routes that must always render in light mode (e.g. payment forms, print views)
const FORCE_LIGHT_ROUTES = ['/payment', '/print'];

const ThemeCtx = createContext<{
  theme:  Theme;
  toggle: () => void;
}>({ theme: 'light', toggle: () => {} });

export function ThemeProvider({ children }: { children: ReactNode }) {
  const location = useLocation();
  const [theme, setTheme] = useState<Theme>(
    () => (localStorage.getItem(STORAGE_KEY) as Theme) || 'light'
  );

  // Check route overrides
  const forcedLight = FORCE_LIGHT_ROUTES.some(r => location.pathname.startsWith(r));
  const effectiveTheme = forcedLight ? 'light' : theme;

  useEffect(() => {
    document.documentElement.classList.toggle('dark', effectiveTheme === 'dark');
    document.body.setAttribute('data-theme', effectiveTheme);
  }, [effectiveTheme]);

  const toggle = () => {
    const next: Theme = theme === 'light' ? 'dark' : 'light';
    localStorage.setItem(STORAGE_KEY, next);
    setTheme(next);
    topic.publish('theme:changed', next);
  };

  // Listen for changes from other tabs / components
  useEffect(() => {
    const unsub = topic.subscribe('theme:changed', (t: Theme) => setTheme(t));
    return unsub;
  }, []);

  return (
    <ThemeCtx.Provider value={{ theme: effectiveTheme, toggle }}>
      {children}
    </ThemeCtx.Provider>
  );
}

export const useTheme = () => useContext(ThemeCtx);

Toggle button#

// src/components/ThemeToggle.tsx
import { ActionIcon } from '@mantine/core';
import { IconSun, IconMoon } from '@tabler/icons-react';
import { useTheme } from '../wire/ThemeProvider';

export default function ThemeToggle() {
  const { theme, toggle } = useTheme();
  return (
    <ActionIcon variant="subtle" onClick={toggle} aria-label="Toggle theme">
      {theme === 'dark' ? <IconSun size={18} /> : <IconMoon size={18} />}
    </ActionIcon>
  );
}

Wiring#

Wrap inside your router (after BrowserRouter) so useLocation is available:

<BrowserRouter>
  <MantineProvider>
    <ThemeProvider>
      <App />
    </ThemeProvider>
  </MantineProvider>
</BrowserRouter>

Add <ThemeToggle /> in your header/navbar.

CSS#

/* Global dark-mode overrides */
html.dark {
  color-scheme: dark;
}
html.dark body {
  background-color: #1a1b1e;
  color: #c1c2c5;
}

Mantine components respect color-scheme: dark automatically if you use Mantine’s CSS variables. For custom components, use [data-theme="dark"] selectors.

See also: PalmyraStoreFactory.