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: --primary not --blue
  • • Always define both light and dark theme values
  • • Use prefers-color-scheme media 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)