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.