Back to Design Systems
Topic 6 of 8
Theming & Customization
Implement dark mode, brand theming, and user-customizable interfaces
Why Theming Matters
Modern applications need to support dark mode, brand customization, and user preferences. A good theming system lets you change the entire look of your app by modifying a few variables, while keeping your component code clean.
🎨 Theming Strategies
CSS Custom Properties
Native CSS variables, works everywhere
CSS-in-JS Themes
Theme objects in styled-components, Emotion
Tailwind + CSS Variables
Best of both worlds
React Context
Theme provider pattern
CSS Custom Properties for Theming
/* tokens.css */
:root {
/* Light theme (default) */
--background: #ffffff;
--foreground: #0a0a0a;
--card: #ffffff;
--card-foreground: #0a0a0a;
--primary: #3b82f6;
--primary-foreground: #ffffff;
--secondary: #f1f5f9;
--secondary-foreground: #1e293b;
--muted: #f1f5f9;
--muted-foreground: #64748b;
--border: #e2e8f0;
--ring: #3b82f6;
}
/* Dark theme */
.dark {
--background: #0a0a0a;
--foreground: #fafafa;
--card: #1c1c1c;
--card-foreground: #fafafa;
--primary: #60a5fa;
--primary-foreground: #0a0a0a;
--secondary: #27272a;
--secondary-foreground: #fafafa;
--muted: #27272a;
--muted-foreground: #a1a1aa;
--border: #27272a;
--ring: #60a5fa;
}
/* Using the tokens */
.card {
background-color: var(--card);
color: var(--card-foreground);
border: 1px solid var(--border);
}
.button-primary {
background-color: var(--primary);
color: var(--primary-foreground);
}
Dark Mode Toggle in React
// hooks/useTheme.ts
import { useEffect, useState } from 'react';
type Theme = 'light' | 'dark' | 'system';
export function useTheme() {
const [theme, setTheme] = useState(() => {
if (typeof window === 'undefined') return 'system';
return (localStorage.getItem('theme') as Theme) || 'system';
});
useEffect(() => {
const root = document.documentElement;
const applyTheme = (t: Theme) => {
if (t === 'system') {
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
root.classList.toggle('dark', systemDark);
} else {
root.classList.toggle('dark', t === 'dark');
}
};
applyTheme(theme);
localStorage.setItem('theme', theme);
// Listen for system preference changes
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handler = () => theme === 'system' && applyTheme('system');
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}, [theme]);
return { theme, setTheme };
}
// ThemeToggle component
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
);
}
next-themes (Next.js)
For Next.js apps, next-themes handles SSR, system preference, and flash prevention:
npm install next-themes
// app/providers.tsx
'use client';
import { ThemeProvider } from 'next-themes';
export function Providers({ children }: { children: React.ReactNode }) {
return (
{children}
);
}
// app/layout.tsx
import { Providers } from './providers';
export default function RootLayout({ children }) {
return (
{children}
);
}
// ThemeToggle.tsx
'use client';
import { useTheme } from 'next-themes';
import { Sun, Moon, Monitor } from 'lucide-react';
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
);
}
Brand Theming / Multi-tenancy
// themes/brands.ts
export const brands = {
default: {
primary: '#3b82f6',
primaryForeground: '#ffffff',
accent: '#f59e0b',
},
acme: {
primary: '#10b981',
primaryForeground: '#ffffff',
accent: '#8b5cf6',
},
corp: {
primary: '#ef4444',
primaryForeground: '#ffffff',
accent: '#06b6d4',
},
};
// Apply brand theme
function applyBrandTheme(brand: keyof typeof brands) {
const theme = brands[brand];
const root = document.documentElement;
Object.entries(theme).forEach(([key, value]) => {
// Convert camelCase to kebab-case for CSS
const cssVar = key.replace(/([A-Z])/g, '-$1').toLowerCase();
root.style.setProperty(`--${cssVar}`, value);
});
}
// Usage: applyBrandTheme('acme');
// Or in Tailwind config
// tailwind.config.js
module.exports = {
theme: {
extend: {
colors: {
primary: 'var(--primary)',
'primary-foreground': 'var(--primary-foreground)',
accent: 'var(--accent)',
},
},
},
};
Tailwind + CSS Variables (shadcn/ui approach)
/* globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
}
}
// tailwind.config.js
module.exports = {
darkMode: 'class',
theme: {
extend: {
colors: {
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
},
},
};
// Now use Tailwind classes that reference CSS variables
Color Scheme Utilities
// Generate consistent color palettes
// Use tools like https://www.tailwindshades.com/
// Or programmatically with color manipulation
import { colord, extend } from 'colord';
import a11yPlugin from 'colord/plugins/a11y';
extend([a11yPlugin]);
function generatePalette(baseColor: string) {
const color = colord(baseColor);
return {
50: color.lighten(0.45).toHex(),
100: color.lighten(0.35).toHex(),
200: color.lighten(0.25).toHex(),
300: color.lighten(0.15).toHex(),
400: color.lighten(0.05).toHex(),
500: color.toHex(),
600: color.darken(0.1).toHex(),
700: color.darken(0.2).toHex(),
800: color.darken(0.3).toHex(),
900: color.darken(0.4).toHex(),
};
}
// Check contrast for accessibility
function getContrastColor(bgColor: string) {
const color = colord(bgColor);
return color.isLight() ? '#000000' : '#ffffff';
}
// Ensure WCAG AA compliance
function checkContrast(foreground: string, background: string) {
const ratio = colord(foreground).contrast(background);
return {
ratio,
passesAA: ratio >= 4.5,
passesAAA: ratio >= 7,
};
}
💡 Best Practices
- • Use semantic token names:
--primarynot--blue - • Always define both light and dark theme values
- • Use
prefers-color-schememedia query for system detection - • Persist theme preference in localStorage
- • Prevent flash of wrong theme on page load (use next-themes or blocking script)
- • Test color contrast for accessibility (4.5:1 minimum)