Global Theme System
In this tutorial, we'll build a global theme system that allows users to switch between light and dark modes. The theme will be shared across all components and persist between sessions.
What We'll Build
Global Theme State
Shared theme state that updates all components simultaneously.
Persistent Preferences
Theme preference is saved and restored automatically.
System Integration
Detects and respects user's system theme preference.
Step 1: Create Global Theme State
Let's start by creating a global theme refraction:
Theme Store
Creating global theme state with system preference detection:
// store/theme.js
import { createRefraction } from "refract";
// Detect system theme preference
const getSystemTheme = () => {
if (typeof window !== 'undefined') {
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
}
return 'light';
};
// Load saved theme or use system preference
const getSavedTheme = () => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('theme-preference');
if (saved && (saved === 'light' || saved === 'dark')) {
return saved;
}
}
return getSystemTheme();
};
// Global theme state
export const globalTheme = createRefraction(getSavedTheme());
// Theme definitions
export const themes = {
light: {
name: 'Light',
colors: {
background: '#ffffff',
surface: '#f8f9fa',
text: '#212529',
textSecondary: '#6c757d',
primary: '#007bff',
secondary: '#6c757d',
border: '#dee2e6',
shadow: 'rgba(0, 0, 0, 0.1)'
}
},
dark: {
name: 'Dark',
colors: {
background: '#1a1a1a',
surface: '#2d2d2d',
text: '#ffffff',
textSecondary: '#b0b0b0',
primary: '#0d6efd',
secondary: '#adb5bd',
border: '#404040',
shadow: 'rgba(0, 0, 0, 0.3)'
}
}
};
// Helper function to get current theme object
export const getCurrentTheme = (themeName) => themes[themeName] || themes.light;
Step 2: Create Theme Provider
Now let's create a component that applies the theme globally:
Theme Provider Component
Component that applies theme styles to the entire application:
// components/ThemeProvider.js
import { createComponent } from "refract";
import { globalTheme, themes, getCurrentTheme } from "../store/theme.js";
const ThemeProvider = createComponent(({ lens, children }) => {
const theme = lens.useRefraction(globalTheme);
// Apply theme to CSS custom properties
lens.useEffect(() => {
const themeColors = getCurrentTheme(theme.value).colors;
const root = document.documentElement;
// Set CSS custom properties
Object.entries(themeColors).forEach(([key, value]) => {
root.style.setProperty(`--color-${key}`, value);
});
// Add theme class to body
document.body.className = `theme-${theme.value}`;
}, [theme.value]);
// Save theme preference
lens.useEffect(() => {
localStorage.setItem('theme-preference', theme.value);
}, [theme.value]);
// Listen for system theme changes
lens.useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (e) => {
// Only update if user hasn't manually set a preference
const savedTheme = localStorage.getItem('theme-preference');
if (!savedTheme) {
theme.set(e.matches ? 'dark' : 'light');
}
};
mediaQuery.addEventListener('change', handleChange);
return () => {
mediaQuery.removeEventListener('change', handleChange);
};
}, []);
return (
<div className="theme-provider">
{children}
</div>
);
});
export default ThemeProvider;
Step 3: Create Theme Toggle Component
Let's create a component for switching themes:
Theme Toggle Component
Interactive component for switching between themes:
// components/ThemeToggle.js
import { createComponent } from "refract";
import { globalTheme, themes } from "../store/theme.js";
const ThemeToggle = createComponent(({ lens }) => {
const theme = lens.useRefraction(globalTheme);
const toggleTheme = () => {
const newTheme = theme.value === 'light' ? 'dark' : 'light';
theme.set(newTheme);
};
const currentTheme = themes[theme.value];
const nextTheme = themes[theme.value === 'light' ? 'dark' : 'light'];
return (
<button
className="theme-toggle"
onClick={toggleTheme}
title={`Switch to ${nextTheme.name} theme`}
>
<span className="theme-toggle__icon">
{theme.value === 'light' ? '🌙' : '☀️'}
</span>
<span className="theme-toggle__text">
{nextTheme.name}
</span>
</button>
);
});
export default ThemeToggle;
Step 4: Add Theme-Aware Components
Let's create components that respond to theme changes:
Theme-Aware Card Component
A card component that adapts to the current theme:
// components/Card.js
import { createComponent } from "refract";
import { globalTheme, getCurrentTheme } from "../store/theme.js";
const Card = createComponent(({ lens, title, children, variant = 'default' }) => {
const theme = lens.useRefraction(globalTheme);
const themeColors = getCurrentTheme(theme.value).colors;
return (
<div
className={`card card--${variant} card--${theme.value}`}
style={{
backgroundColor: themeColors.surface,
color: themeColors.text,
borderColor: themeColors.border,
boxShadow: `0 2px 8px ${themeColors.shadow}`
}}
>
{title && (
<div
className="card__header"
style={{ borderBottomColor: themeColors.border }}
>
<h3 style={{ color: themeColors.text }}>{title}</h3>
</div>
)}
<div className="card__content">
{children}
</div>
</div>
);
});
export default Card;
Step 5: Create CSS Variables
Let's add CSS that uses the theme variables:
Theme Styles
CSS that responds to theme changes using custom properties:
:root {
/*Default light theme colors*/
--color-background: #ffffff;
--color-surface: #f8f9fa;
--color-text: #212529;
--color-text-secondary: #6c757d;
--color-primary: #007bff;
--color-secondary: #6c757d;
--color-border: #dee2e6;
--color-shadow: rgba(0, 0, 0, 0.1);
/*Transitions*/
--transition-theme: all 0.3s ease;
}
body {
background-color: var(--color-background);
color: var(--color-text);
transition: var(--transition-theme);
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.theme-provider {
min-height: 100vh;
background-color: var(--color-background);
transition: var(--transition-theme);
}
.theme-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background-color: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: 8px;
cursor: pointer;
transition: var(--transition-theme);
font-size: 0.9rem;
}
.theme-toggle:hover {
background-color: var(--color-primary);
color: white;
transform: translateY(-1px);
}
.theme-toggle__icon {
font-size: 1.2rem;
}
.card {
padding: 1.5rem;
border-radius: 12px;
border: 1px solid var(--color-border);
margin-bottom: 1rem;
transition: var(--transition-theme);
}
.card__header {
margin: -1.5rem -1.5rem 1rem -1.5rem;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--color-border);
}
.card__header h3 {
margin: 0;
font-size: 1.2rem;
}
.card__content {
line-height: 1.6;
}
/*Theme-specific adjustments*/
.theme-dark .card {
box-shadow: 0 4px 12px var(--color-shadow);
}
.theme-light .card {
box-shadow: 0 2px 8px var(--color-shadow);
}
Step 6: Put It All Together
Now let's create the main application that uses our theme system:
Complete Application
Main app component that demonstrates the theme system:
- App.js
- main.js
import { createComponent } from "refract";
import ThemeProvider from "./components/ThemeProvider.js";
import ThemeToggle from "./components/ThemeToggle.js";
import Card from "./components/Card.js";
import { globalTheme } from "./store/theme.js";
import "./styles/theme.css";
const App = createComponent(({ lens }) => {
const theme = lens.useRefraction(globalTheme);
return (
<ThemeProvider>
<div className="app">
<header className="app-header">
<h1>Theme System Demo</h1>
<ThemeToggle />
</header>
<main className="app-main">
<Card title="Welcome">
<p>
This application demonstrates Refract's global theme system.
The current theme is <strong>{theme.value}</strong>.
</p>
<p>
Try clicking the theme toggle button to switch between light
and dark modes. Your preference will be saved automatically!
</p>
</Card>
<Card title="Features">
<ul>
<li>🌓 Global theme state shared across components</li>
<li>💾 Automatic persistence to localStorage</li>
<li>🎨 System theme preference detection</li>
<li>⚡ Smooth transitions between themes</li>
<li>🔄 Reactive updates across all components</li>
</ul>
</Card>
<Card title="Theme Colors">
<div className="color-palette">
<div className="color-swatch" style={{backgroundColor: 'var(--color-primary)'}}>
Primary
</div>
<div className="color-swatch" style={{backgroundColor: 'var(--color-secondary)'}}>
Secondary
</div>
<div className="color-swatch" style={{backgroundColor: 'var(--color-surface)'}}>
Surface
</div>
</div>
</Card>
</main>
</div>
</ThemeProvider>
);
});
export default App;
import { createApp } from "refract";
import App from "./App.js";
const app = createApp(App);
app.mount("#root");
Add the additional styles for the app layout:
App Layout Styles
Additional CSS for the application layout:
.app {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
.app-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--color-border);
}
.app-header h1 {
margin: 0;
color: var(--color-text);
font-size: 2rem;
}
.app-main {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.color-palette {
display: flex;
gap: 1rem;
margin-top: 1rem;
}
.color-swatch {
padding: 1rem;
border-radius: 8px;
color: white;
font-weight: 500;
text-align: center;
min-width: 100px;
border: 1px solid var(--color-border);
}
@media (max-width: 768px) {
.app {
padding: 1rem;
}
.app-header {
flex-direction: column;
gap: 1rem;
text-align: center;
}
.color-palette {
flex-direction: column;
}
}
Advanced Features
Theme Persistence with Custom Hook
Let's create a custom optic for theme management:
useTheme Optic
Custom optic that encapsulates theme logic:
// optics/useTheme.js
import { useRefraction, useOptic } from "refract";
import { globalTheme, themes, getCurrentTheme } from "../store/theme.js";
export function useTheme() {
const theme = useRefraction(globalTheme);
// Apply theme to document
useOptic(() => {
const themeColors = getCurrentTheme(theme.value).colors;
const root = document.documentElement;
Object.entries(themeColors).forEach(([key, value]) => {
root.style.setProperty(`--color-${key}`, value);
});
document.body.className = `theme-${theme.value}`;
localStorage.setItem('theme-preference', theme.value);
}, [theme.value]);
const toggleTheme = () => {
theme.set(theme.value === 'light' ? 'dark' : 'light');
};
const setTheme = (newTheme) => {
if (themes[newTheme]) {
theme.set(newTheme);
}
};
return {
currentTheme: theme.value,
themeObject: getCurrentTheme(theme.value),
availableThemes: Object.keys(themes),
toggleTheme,
setTheme,
isLight: theme.value === 'light',
isDark: theme.value === 'dark'
};
}
// Usage in components
const MyComponent = createComponent(({ lens }) => {
const theme = lens.useOptic(() => useTheme());
return (
<div>
<p>Current theme: {theme.currentTheme}</p>
<button onClick={theme.toggleTheme}>
Switch to {theme.isLight ? 'dark' : 'light'} mode
</button>
</div>
);
});
System Theme Detection
Enhance the theme system to better handle system preferences:
Enhanced System Integration
Better system theme detection and handling:
// store/theme.js (enhanced version)
import { createRefraction } from "refract";
const THEME_STORAGE_KEY = 'theme-preference';
const THEME_AUTO = 'auto';
// Enhanced theme detection
const getSystemTheme = () => {
if (typeof window === 'undefined') return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
};
const getInitialTheme = () => {
if (typeof window === 'undefined') return 'light';
const saved = localStorage.getItem(THEME_STORAGE_KEY);
if (saved === THEME_AUTO || !saved) {
return getSystemTheme();
}
return saved === 'dark' ? 'dark' : 'light';
};
export const globalTheme = createRefraction(getInitialTheme());
export const themePreference = createRefraction(
typeof window !== 'undefined'
? localStorage.getItem(THEME_STORAGE_KEY) || THEME_AUTO
: THEME_AUTO
);
// Enhanced themes with more options
export const themes = {
light: {
name: 'Light',
colors: {
background: '#ffffff',
surface: '#f8f9fa',
surfaceHover: '#e9ecef',
text: '#212529',
textSecondary: '#6c757d',
primary: '#007bff',
primaryHover: '#0056b3',
secondary: '#6c757d',
success: '#28a745',
warning: '#ffc107',
danger: '#dc3545',
border: '#dee2e6',
shadow: 'rgba(0, 0, 0, 0.1)',
shadowHover: 'rgba(0, 0, 0, 0.15)'
}
},
dark: {
name: 'Dark',
colors: {
background: '#121212',
surface: '#1e1e1e',
surfaceHover: '#2d2d2d',
text: '#ffffff',
textSecondary: '#b0b0b0',
primary: '#0d6efd',
primaryHover: '#0b5ed7',
secondary: '#adb5bd',
success: '#198754',
warning: '#fd7e14',
danger: '#dc3545',
border: '#404040',
shadow: 'rgba(0, 0, 0, 0.3)',
shadowHover: 'rgba(0, 0, 0, 0.4)'
}
}
};
export const getCurrentTheme = (themeName) => themes[themeName] || themes.light;
What You've Learned
Congratulations! You've built a comprehensive global theme system that demonstrates:
Global State
Used createRefraction() to create shared state across all components.
Reactive Updates
All components automatically update when the theme changes.
CSS Integration
Dynamic CSS custom properties that respond to theme changes.
Key Takeaways
- Global refractions with
createRefraction()share state across components - Side effects can apply changes to the DOM and external systems
- CSS custom properties provide a clean way to theme applications
- System integration respects user preferences and accessibility
- Persistence maintains user choices across sessions
Next Steps
Try these enhancements to extend the theme system:
- Add more themes - Create additional color schemes
- Theme scheduling - Automatically switch themes based on time of day
- Component-specific themes - Allow individual components to override colors
- Animation preferences - Respect
prefers-reduced-motionsetting - High contrast mode - Add accessibility-focused themes
Ready for more advanced patterns? Check out our API documentation for detailed information about all Refract features!