Skip to main content

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:

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;

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:

  1. Add more themes - Create additional color schemes
  2. Theme scheduling - Automatically switch themes based on time of day
  3. Component-specific themes - Allow individual components to override colors
  4. Animation preferences - Respect prefers-reduced-motion setting
  5. High contrast mode - Add accessibility-focused themes

Ready for more advanced patterns? Check out our API documentation for detailed information about all Refract features!