8.3 KiB
Theme System
Dynamic theme system with light/dark modes and 22 customizable color palettes. Uses CSS variables for instant theme switching with Zustand state management.
Public pages are styled by portal color schemes instead, which override these tokens while mounted; see Discovery Portal.
Architecture
User Preferences → Zustand Store → CSS Variables → UI
↓
localStorage
↓
Backend Sync
Theme Modes
Light Mode
Clean, bright interface for daylight viewing.
import { useThemeStore } from '@/store/theme';
const { setMode } = useThemeStore();
setMode('light');
Characteristics:
- Light backgrounds (98% lightness)
- Dark text on light backgrounds
- Subtle shadows
- Vibrant primary colors
Dark Mode
Eye-friendly interface for low-light viewing.
setMode('dark');
Characteristics:
- Deep backgrounds (4.9% lightness)
- Light text on dark
- Reduced brightness
- Slightly muted primary colors
System Mode (Auto)
Automatically matches OS preference.
setMode('system');
Listens to prefers-color-scheme media query and updates when system preference
changes.
Primary Color Customization
22 color palettes available:
import { useThemeStore } from '@/store/theme';
const { primaryColor, setPrimaryColor } = useThemeStore();
setPrimaryColor('purple');
setPrimaryColor('green');
setPrimaryColor('rose');
Available Colors: Neutral: Slate, Gray, Zinc, Neutral, Stone Warm: Red, Orange, Amber, Yellow Cool: Lime, Green, Emerald, Teal, Cyan, Sky, Blue Purple: Indigo, Violet, Purple, Fuchsia Pink: Pink, Rose
CSS Variables
All theme values use CSS custom properties for dynamic updates.
Light Mode Root
:root {
--background: 0 0% 98%;
--foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%; /* Blue default */
--secondary: 210 40% 96.1%;
--destructive: 0 84.2% 60.2%;
--muted: 210 40% 96.1%;
--accent: 210 40% 96.1%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
}
Dark Mode
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%; /* Lighter blue */
--secondary: 217.2 32.6% 17.5%;
--destructive: 0 62.8% 30.6%;
--border: 217.2 32.6% 17.5%;
}
Usage
// Tailwind automatically uses CSS variables
<div className="bg-background text-foreground border-border" />
// With opacity
<div className="bg-primary/20" />
<div className="text-foreground/70" />
State Management
Theme store using Zustand with localStorage persistence:
interface ThemeState {
mode: 'light' | 'dark' | 'system';
primaryColor: PrimaryColor;
isLoading: boolean;
setMode: (mode: ThemeMode) => void;
setPrimaryColor: (color: PrimaryColor) => void;
loadThemeFromServer: () => Promise<void>;
syncThemeToServer: () => Promise<void>;
}
Complete Usage
import { useThemeStore } from '@/store/theme';
function ThemeSettings() {
const { mode, setMode, primaryColor, setPrimaryColor, isLoading } =
useThemeStore();
return (
<div>
<select value={mode} onChange={(e) => setMode(e.target.value)}>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="system">System</option>
</select>
<select
value={primaryColor}
onChange={(e) => setPrimaryColor(e.target.value)}
>
<option value="blue">Blue</option>
<option value="purple">Purple</option>
<option value="green">Green</option>
</select>
{isLoading && <p>Syncing...</p>}
</div>
);
}
Theme Components
ThemePicker
Dropdown for theme mode selection:
import { ThemePicker } from '@/components/theme/ThemePicker';
function Header() {
return (
<header>
<nav>
{/* ... nav items ... */}
<ThemePicker />
</nav>
</header>
);
}
Features:
- 3 options (Light, Dark, System)
- Icon changes based on current mode
- Keyboard accessible
- ARIA labels
PrimaryColorPicker
Color palette selector:
import { PrimaryColorPicker } from '@/components/theme/PrimaryColorPicker';
function Settings() {
return (
<div>
<h2>Customize Colors</h2>
<PrimaryColorPicker />
</div>
);
}
Features:
- Visual color swatches
- Grouped by category
- Live preview
- Scrollable for all 22 colors
Theme Persistence
Three-layer persistence system:
1. LocalStorage (Client)
Automatically persisted via Zustand:
{
"state": {
"mode": "dark",
"primaryColor": "purple"
},
"version": 0
}
Storage key: flashpoint-theme-settings
2. Server Sync
Authenticated users sync to backend:
await usersApi.updateThemeSettings(mode, primaryColor);
const settings = await usersApi.getThemeSettings();
Endpoints:
GET /api/users/me/theme- FetchPUT /api/users/me/theme- Update
3. Rehydration Flow
- App loads → Read localStorage
- Apply theme immediately (no flash)
- If authenticated → Fetch from server
- Merge server preferences
- User changes → Update all layers
Dynamic Theme Updates
Theme changes apply instantly without page reload:
// 1. User selects new theme
setMode('dark');
// 2. Store updates
set({ mode: 'dark' });
// 3. Apply to DOM
document.documentElement.classList.remove('light');
document.documentElement.classList.add('dark');
// 4. Sync to server (non-blocking)
syncThemeToServer();
Primary Color Application
// Update CSS variable dynamically
const colorValue = colorPalette[color][theme];
document.documentElement.style.setProperty('--primary', colorValue);
document.documentElement.style.setProperty('--ring', colorValue);
System Theme Detection
Listens to OS-level theme changes in "system" mode:
window
.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', (e) => {
const state = useThemeStore.getState();
if (state.mode === 'system') {
applyTheme('system');
applyPrimaryColor(state.primaryColor, 'system');
}
});
Supports:
- Windows dark mode toggle
- macOS dark mode schedule
- Linux desktop themes
- Browser preferences
Accessibility
Focus Rings
Focus rings use primary color for consistency:
:focus-visible {
outline: none;
ring: 2px;
ring-color: hsl(var(--ring));
ring-offset: 2px;
ring-offset-color: hsl(var(--background));
}
Color Contrast
All palettes meet WCAG 2.1 AA:
- Normal text: 4.5:1 ratio
- Large text: 3:1 ratio
- Interactive: 3:1 ratio
Reduced Motion
Respects prefers-reduced-motion:
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
Best Practices
1. Use Semantic Tokens
// Good: Adapts to theme
<div className="bg-background text-foreground" />
// Bad: Breaks theming
<div className="bg-white text-black" />
2. Test Both Themes
Toggle themes frequently during development:
const { setMode } = useThemeStore();
<button onClick={() => setMode(mode === 'dark' ? 'light' : 'dark')}>
Toggle Theme
</button>;
3. Respect User Preferences
Never force a theme. Let users choose or use system preference.
// Good: Let user choose
const { mode } = useThemeStore();
// Bad: Force theme
document.documentElement.classList.add('dark');
4. Avoid Theme-Specific Logic
Design components to work in any theme:
// Good: Works in any theme
<Card className="bg-card text-card-foreground">Content</Card>;
// Bad: Theme-specific components
{
mode === 'dark' ? <DarkCard /> : <LightCard />;
}
Troubleshooting
Theme Not Applying
- Check
darkclass on<html>element - Verify CSS variables in
index.css - Ensure Tailwind
darkMode: ["class"] - Clear localStorage and refresh
Colors Look Wrong
- Verify HSL values in
colorPalette - Check primary color applied to
--primary - Test in both light and dark modes
- Check for CSS conflicts
System Mode Not Working
- Check browser support for
prefers-color-scheme - Verify media query listener is attached
- Test by changing OS setting
- Check browser's own dark mode setting
Next: Color Palette - Detailed color definitions