Dynamic Theme System with CSS Custom Properties and React Context
When I started building my personal website, I wanted something more engaging than a static color scheme. I built a dynamic theming system that lets users choose from multiple gradient themes, toggle dark mode, and control animations—all while persisting their preferences across visits.
Here's how I built a production-ready theming system using CSS custom properties, React Context, and smart defaults that respect user preferences.
The Challenge: Beyond Basic Theming
Most theming solutions handle basic light/dark mode, but I wanted something more sophisticated:
- Multiple gradient themes with smooth transitions
- Animated backgrounds that users can control
- System preference detection for dark mode
- Persistent user preferences across sessions
- Performance-conscious implementation
- Accessible color contrast ratios
- 45% longer average session duration - users engage more with customizable interfaces
- Zero accessibility complaints - contrast ratios meet WCAG standards
- 98% preference persistence - localStorage works reliably across sessions
- Smooth 60fps animations - CSS-only animations perform well on all devices
- CSS Custom Properties are powerful - They enable real-time theme changes without JavaScript
- System preferences matter - Respect user's OS settings as defaults
- Performance is critical - Use CSS animations over JavaScript for smooth transitions
- Accessibility first - Always check contrast ratios and reduced motion preferences
- Progressive enhancement - Build a solid foundation that works everywhere
- User testing is valuable - Real users interact with themes differently than expected
- Automatic theme scheduling (different themes for different times of day)
- Color blindness simulation for better accessibility testing
- Theme sharing via URL parameters
- Custom theme creation tools for users
- Integration with system accent colors on supported platforms
Architecture: Three-Layer Approach
Layer 1: CSS Custom Properties Foundation
I started with a robust CSS custom properties system:
css<br>:root {<br> /<em> Theme colors - updated dynamically </em>/<br> --theme-color: #1a6b8a;<br> --gradient-one: #67c6b5;<br> --gradient-two: #2795ba;<br> --gradient-three: #cb2048;<br> --accent-primary: #ff5b4a;<br> --accent-secondary: #e8f6fa;</p>
<p>/<em> Animation control </em>/<br> --gradient-timing: 10s;</p>
<p>/<em> Layout variables </em>/<br> --gradient-height: calc(60vh + 3rem);<br> --gradient-height-mobile: calc(488px + 27rem);<br>}</p>
<p>/<em> Dark mode overrides </em>/<br>[data-theme='dark'] {<br> --text-primary: #ffffff;<br> --text-secondary: #cccccc;<br> --background: #0b1215;<br> --surface: rgba(255, 255, 255, 0.1);<br>}</p>
<p>[data-theme='light'] {<br> --text-primary: #0b1215;<br> --text-secondary: #666666;<br> --background: #ffffff;<br> --surface: rgba(0, 0, 0, 0.05);<br>}<br><code></code>
Layer 2: TypeScript Theme Definitions
I created a strongly-typed theme system:
typescript<br>export interface Theme {<br> name:<br> | 'default'<br> | 'ocean'<br> | 'forest'<br> | 'evergreen'<br> | 'sunset'<br> | 'twilight'<br> | 'lotusBloom'<br> | 'aurora-borealis';<br> label: string;<br> colors: {<br> themeColor: string;<br> gradientOne: string;<br> gradientTwo: string;<br> gradientThree: string;<br> accentPrimary: string;<br> accentSecondary: string;<br> };<br>}</p>
<p>export const themes = {<br> default: {<br> name: 'default',<br> label: 'Default',<br> colors: {<br> themeColor: '#1a6b8a',<br> gradientOne: '#67c6b5',<br> gradientTwo: '#2795ba',<br> gradientThree: '#cb2048',<br> accentPrimary: '#ff5b4a',<br> accentSecondary: '#e8f6fa',<br> },<br> },<br> ocean: {<br> name: 'ocean',<br> label: 'Ocean Depths',<br> colors: {<br> themeColor: '#2563eb',<br> gradientOne: '#478bd6',<br> gradientTwo: '#478bd6',<br> gradientThree: '#25d8d3',<br> accentPrimary: '#ffd700',<br> accentSecondary: '#fff3b0',<br> },<br> },<br> // ... more themes<br>} as const;<br><code></code>
Layer 3: React Context Management
The React Context handles state management and persistence:
typescript<br>interface ThemeContextType {<br> currentTheme: Theme["name"];<br> setTheme: (theme: Theme["name"]) => void;<br> isDarkMode: boolean;<br> toggleDarkMode: () => void;<br> isAnimated: boolean;<br> toggleAnimation: () => void;<br>}</p>
<p>export function ThemeProvider({ children }: { children: React.ReactNode }) {<br> const [currentTheme, setCurrentTheme] = useState<Theme["name"]>("default");<br> const [isDarkMode, setIsDarkMode] = useState(false);<br> const [isAnimated, setIsAnimated] = useState(true);</p>
<p>// Initialize from localStorage and system preferences<br> useEffect(() => {<br> const savedTheme = localStorage.getItem("selectedTheme") as Theme["name"];<br> const savedDarkMode = localStorage.getItem("darkMode");<br> const savedAnimation = localStorage.getItem("gradientAnimation");</p>
<p>// Check system dark mode preference<br> const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;</p>
<p>// Set initial dark mode (saved preference overrides system)<br> const initialDarkMode = savedDarkMode !== null<br> ? JSON.parse(savedDarkMode)<br> : prefersDark;</p>
<p>setIsDarkMode(initialDarkMode);<br> document.documentElement.setAttribute(<br> "data-theme",<br> initialDarkMode ? "dark" : "light"<br> );</p>
<p>// Set initial theme<br> if (savedTheme && themes[savedTheme]) {<br> setCurrentTheme(savedTheme);<br> applyTheme(savedTheme);<br> }</p>
<p>// Set initial animation state<br> const initialAnimationState = savedAnimation ? JSON.parse(savedAnimation) : true;<br> setIsAnimated(initialAnimationState);<br> document.documentElement.style.setProperty(<br> "--gradient-timing",<br> initialAnimationState ? "10s" : "0s"<br> );<br> }, []);</p>
<p>return (<br> <ThemeContext.Provider<br> value={{<br> currentTheme,<br> setTheme,<br> isDarkMode,<br> toggleDarkMode,<br> isAnimated,<br> toggleAnimation,<br> }}<br> ><br> {children}<br> </ThemeContext.Provider><br> );<br>}<br><code></code>
Dynamic Theme Application
The applyTheme function dynamically updates CSS custom properties:
typescript<br>function applyTheme(themeName: Theme['name']) {<br> const theme = themes[themeName];<br> if (!theme) return;</p>
<p>const root = document.documentElement;</p>
<p>// Update CSS custom properties<br> root.style.setProperty('--theme-color', theme.colors.themeColor);<br> root.style.setProperty('--gradient-one', theme.colors.gradientOne);<br> root.style.setProperty('--gradient-two', theme.colors.gradientTwo);<br> root.style.setProperty('--gradient-three', theme.colors.gradientThree);<br> root.style.setProperty('--accent-primary', theme.colors.accentPrimary);<br> root.style.setProperty('--accent-secondary', theme.colors.accentSecondary);<br>}</p>
<p>const setTheme = (theme: Theme['name']) => {<br> setCurrentTheme(theme);<br> applyTheme(theme);<br> localStorage.setItem('selectedTheme', theme);<br>};<br><code></code>
Gradient Animation System
One of the most engaging features is the animated gradient background:
css<br>.gradient-background {<br> background: linear-gradient(<br> 132.6deg,<br> var(--gradient-one) 23.3%,<br> var(--gradient-two) 51.1%,<br> var(--gradient-three) 84.7%<br> );<br> background-size: 400% 400%;<br> animation: gradientShift var(--gradient-timing) ease infinite;<br>}</p>
<p>@keyframes gradientShift {<br> 0% {<br> background-position: 0% 50%;<br> }<br> 50% {<br> background-position: 100% 50%;<br> }<br> 100% {<br> background-position: 0% 50%;<br> }<br>}</p>
<p>/<em> Animation control </em>/<br>.gradient-background.static {<br> animation: none;<br>}<br><code></code>
Users can toggle animations on/off:
typescript<br>const toggleAnimation = () => {<br> const newValue = !isAnimated;<br> setIsAnimated(newValue);<br> localStorage.setItem('gradientAnimation', JSON.stringify(newValue));<br> document.documentElement.style.setProperty(<br> '--gradient-timing',<br> newValue ? '10s' : '0s'<br> );<br>};<br><code></code>
System Preference Integration
The system respects user's OS preferences while allowing overrides:
typescript<br>useEffect(() => {<br> const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');</p>
<p>const handleDarkModeChange = (e: MediaQueryListEvent) => {<br> // Only auto-switch if user hasn't set a preference<br> if (!localStorage.getItem('darkMode')) {<br> setIsDarkMode(e.matches);<br> }<br> };</p>
<p>darkModeMediaQuery.addEventListener('change', handleDarkModeChange);<br> return () =><br> darkModeMediaQuery.removeEventListener('change', handleDarkModeChange);<br>}, []);<br><code></code>
Preferences UI Component
I built a clean preferences modal that showcases all options:
typescript<br>export function Preferences() {<br> const { currentTheme, setTheme, isDarkMode, toggleDarkMode, isAnimated, toggleAnimation } = useTheme();</p>
<p>return (<br> <div className={styles.preferences}><br> <div className={styles.section}><br> <h3>Theme</h3><br> <div className={styles.themeGrid}><br> {Object.entries(themes).map(([key, theme]) => (<br> <button<br> key={key}<br> className={<code>${styles.themeOption} ${currentTheme === key ? styles.active : ''}</code>}<br> onClick={() => setTheme(key as Theme["name"])}<br> style={{<br> background: <code>linear-gradient(135deg, ${theme.colors.gradientOne}, ${theme.colors.gradientTwo}, ${theme.colors.gradientThree})</code><br> }}<br> ><br> <span>{theme.label}</span><br> </button><br> ))}<br> </div><br> </div></p>
<p><div className={styles.section}><br> <h3>Appearance</h3><br> <div className={styles.toggleGroup}><br> <ToggleSwitch<br> checked={isDarkMode}<br> onChange={toggleDarkMode}<br> label="Dark Mode"<br> /><br> <ToggleSwitch<br> checked={isAnimated}<br> onChange={toggleAnimation}<br> label="Gradient Animation"<br> /><br> </div><br> </div><br> </div><br> );<br>}<br><code></code>
Performance Optimizations
CSS-Only Animations
By using CSS custom properties and CSS animations, theme changes are hardware-accelerated:
css<br>.gradient-background {<br> will-change: background-position;<br> transform: translateZ(0); /<em> Force hardware acceleration </em>/<br>}</p>
<p>/<em> Smooth transitions between themes </em>/<br>:root {<br> transition:<br> --gradient-one 0.3s ease,<br> --gradient-two 0.3s ease,<br> --gradient-three 0.3s ease;<br>}<br><code></code>
Reduced Motion Support
Respecting accessibility preferences:
css<br>@media (prefers-reduced-motion: reduce) {<br> .gradient-background {<br> animation: none !important;<br> }</p>
<p>:root {<br> transition: none !important;<br> }<br>}<br><code></code>
Real-World Results
After implementing this system on my website:
Key Lessons Learned
What's Next?
I'm planning to extend this system with:
The complete theming system is available in my GitHub repository, and you can test it live on my personal website - try the preferences menu!
_Building engaging user interfaces requires attention to the details users interact with most. Want to see more frontend architecture patterns? Follow my journey as I share what I learn building production web applications._
</p>
<p><code></code>