Front-end Engineering Lab
PatternsDesign Systems

Theming Architecture

Build flexible multi-theme support with CSS variables and design tokens

Theming Architecture

Theming allows your application to adapt its visual appearance—light mode, dark mode, brand themes, or user preferences. A solid theming architecture makes this easy to implement and maintain.

Theming Strategies

/* Theme tokens as CSS variables */
:root {
  --color-primary: #3B82F6;
  --color-bg: #FFFFFF;
  --color-text: #000000;
}

[data-theme="dark"] {
  --color-primary: #60A5FA;
  --color-bg: #000000;
  --color-text: #FFFFFF;
}

/* Components use variables */
.button {
  background: var(--color-primary);
  color: var(--color-text);
}

Pros: Native, performant, no JS required
Cons: Limited browser support (IE11)

2. Context + CSS-in-JS

// Theme provider
const themes = {
  light: {
    colors: {
      primary: '#3B82F6',
      background: '#FFFFFF',
      text: '#000000',
    },
  },
  dark: {
    colors: {
      primary: '#60A5FA',
      background: '#000000',
      text: '#FFFFFF',
    },
  },
};

<ThemeProvider theme={themes.light}>
  <App />
</ThemeProvider>

Pros: Full JS power, TypeScript support
Cons: Runtime overhead, larger bundle

3. Tailwind + CSS Variables (Best of Both)

// tailwind.config.js
module.exports = {
  theme: {
    extend: {
      colors: {
        primary: 'var(--color-primary)',
        background: 'var(--color-bg)',
        text: 'var(--color-text)',
      },
    },
  },
};
// Use Tailwind classes
<button className="bg-primary text-text">
  Click me
</button>

Pros: Best of both worlds
Cons: Requires build step

Complete Theming System

Theme Definition

// types/theme.ts
export interface Theme {
  colors: {
    // Brand
    primary: string;
    secondary: string;
    
    // Backgrounds
    background: {
      default: string;
      subtle: string;
      muted: string;
    };
    
    // Text
    text: {
      default: string;
      muted: string;
      subtle: string;
    };
    
    // Borders
    border: {
      default: string;
      hover: string;
      focus: string;
    };
    
    // Feedback
    success: string;
    warning: string;
    error: string;
    info: string;
  };
  
  shadows: {
    sm: string;
    md: string;
    lg: string;
  };
  
  // Add more as needed
}

// themes/light.ts
export const lightTheme: Theme = {
  colors: {
    primary: '#3B82F6',
    secondary: '#6B7280',
    
    background: {
      default: '#FFFFFF',
      subtle: '#F9FAFB',
      muted: '#F3F4F6',
    },
    
    text: {
      default: '#111827',
      muted: '#6B7280',
      subtle: '#9CA3AF',
    },
    
    border: {
      default: '#E5E7EB',
      hover: '#D1D5DB',
      focus: '#3B82F6',
    },
    
    success: '#10B981',
    warning: '#F59E0B',
    error: '#EF4444',
    info: '#3B82F6',
  },
  
  shadows: {
    sm: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
    md: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
    lg: '0 10px 15px -3px rgb(0 0 0 / 0.1)',
  },
};

// themes/dark.ts
export const darkTheme: Theme = {
  colors: {
    primary: '#60A5FA',
    secondary: '#9CA3AF',
    
    background: {
      default: '#000000',
      subtle: '#111827',
      muted: '#1F2937',
    },
    
    text: {
      default: '#F9FAFB',
      muted: '#D1D5DB',
      subtle: '#9CA3AF',
    },
    
    border: {
      default: '#374151',
      hover: '#4B5563',
      focus: '#60A5FA',
    },
    
    success: '#34D399',
    warning: '#FBBF24',
    error: '#F87171',
    info: '#60A5FA',
  },
  
  shadows: {
    sm: '0 1px 2px 0 rgb(0 0 0 / 0.5)',
    md: '0 4px 6px -1px rgb(0 0 0 / 0.5)',
    lg: '0 10px 15px -3px rgb(0 0 0 / 0.5)',
  },
};

Theme Provider

// components/ThemeProvider.tsx
'use client';

import { createContext, useContext, useEffect, useState } from 'react';
import { lightTheme, darkTheme } from '@/themes';
import type { Theme } from '@/types/theme';

type ThemeName = 'light' | 'dark' | 'system';

interface ThemeContextValue {
  theme: ThemeName;
  resolvedTheme: 'light' | 'dark';
  setTheme: (theme: ThemeName) => void;
  tokens: Theme;
}

const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setThemeState] = useState<ThemeName>('system');
  const [mounted, setMounted] = useState(false);

  // Resolve system theme
  const resolvedTheme = theme === 'system'
    ? (typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches)
      ? 'dark'
      : 'light'
    : theme;

  const tokens = resolvedTheme === 'dark' ? darkTheme : lightTheme;

  // Apply theme
  useEffect(() => {
    const root = document.documentElement;
    
    // Set data attribute
    root.setAttribute('data-theme', resolvedTheme);
    
    // Apply CSS variables
    Object.entries(tokens.colors).forEach(([key, value]) => {
      if (typeof value === 'object') {
        Object.entries(value).forEach(([subKey, subValue]) => {
          root.style.setProperty(`--color-${key}-${subKey}`, subValue);
        });
      } else {
        root.style.setProperty(`--color-${key}`, value);
      }
    });
    
    Object.entries(tokens.shadows).forEach(([key, value]) => {
      root.style.setProperty(`--shadow-${key}`, value);
    });
  }, [resolvedTheme, tokens]);

  // Load saved theme
  useEffect(() => {
    const saved = localStorage.getItem('theme') as ThemeName;
    if (saved) {
      setThemeState(saved);
    }
    setMounted(true);
  }, []);

  // Listen to system preference changes
  useEffect(() => {
    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
    
    const handler = () => {
      if (theme === 'system') {
        // Force re-render
        setThemeState('system');
      }
    };
    
    mediaQuery.addEventListener('change', handler);
    return () => mediaQuery.removeEventListener('change', handler);
  }, [theme]);

  const setTheme = (newTheme: ThemeName) => {
    setThemeState(newTheme);
    localStorage.setItem('theme', newTheme);
  };

  // Prevent flash of wrong theme
  if (!mounted) {
    return <>{children}</>;
  }

  return (
    <ThemeContext.Provider value={{ theme, resolvedTheme, setTheme, tokens }}>
      {children}
    </ThemeContext.Provider>
  );
}

export function useTheme() {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error('useTheme must be used within ThemeProvider');
  }
  return context;
}

Theme Switcher

// components/ThemeSwitcher.tsx
'use client';

import { useTheme } from './ThemeProvider';
import { SunIcon, MoonIcon, ComputerIcon } from './icons';

export function ThemeSwitcher() {
  const { theme, setTheme } = useTheme();

  return (
    <div className="theme-switcher">
      <button
        onClick={() => setTheme('light')}
        aria-label="Light theme"
        className={theme === 'light' ? 'active' : ''}
      >
        <SunIcon />
      </button>
      
      <button
        onClick={() => setTheme('dark')}
        aria-label="Dark theme"
        className={theme === 'dark' ? 'active' : ''}
      >
        <MoonIcon />
      </button>
      
      <button
        onClick={() => setTheme('system')}
        aria-label="System theme"
        className={theme === 'system' ? 'active' : ''}
      >
        <ComputerIcon />
      </button>
    </div>
  );
}

Prevent Flash of Unstyled Content

// app/layout.tsx
export default function RootLayout({ children }: Props) {
  return (
    <html lang="en" suppressHydrationWarning>
      <head>
        <script
          dangerouslySetInnerHTML={{
            __html: `
              (function() {
                const theme = localStorage.getItem('theme') || 'system';
                const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
                const resolvedTheme = theme === 'system' ? systemTheme : theme;
                document.documentElement.setAttribute('data-theme', resolvedTheme);
              })();
            `,
          }}
        />
      </head>
      <body>
        <ThemeProvider>
          {children}
        </ThemeProvider>
      </body>
    </html>
  );
}

Brand Themes

// themes/brands.ts
export const brands = {
  default: lightTheme,
  
  acme: {
    ...lightTheme,
    colors: {
      ...lightTheme.colors,
      primary: '#FF6B00',  // Acme brand color
      secondary: '#FFB800',
    },
  },
  
  stark: {
    ...darkTheme,
    colors: {
      ...darkTheme.colors,
      primary: '#FFD700',  // Stark brand color
      secondary: '#C0C0C0',
    },
  },
};

// Usage
<ThemeProvider theme={brands.acme}>
  <App />
</ThemeProvider>

User-Customizable Themes

// Allow users to customize
function CustomThemeEditor() {
  const { tokens, setTheme } = useTheme();
  const [customColors, setCustomColors] = useState(tokens.colors);

  const applyCustomTheme = () => {
    const customTheme = {
      ...tokens,
      colors: customColors,
    };
    
    // Save to localStorage
    localStorage.setItem('customTheme', JSON.stringify(customTheme));
    
    // Apply
    setTheme(customTheme);
  };

  return (
    <div>
      <ColorPicker
        label="Primary Color"
        value={customColors.primary}
        onChange={(color) => setCustomColors({
          ...customColors,
          primary: color,
        })}
      />
      
      <button onClick={applyCustomTheme}>
        Apply Theme
      </button>
    </div>
  );
}

Theme-Aware Components

// Component adapts to theme
import { useTheme } from './ThemeProvider';

export function Card({ children }: { children: React.ReactNode }) {
  const { tokens } = useTheme();

  return (
    <div
      style={{
        backgroundColor: tokens.colors.background.default,
        borderColor: tokens.colors.border.default,
        color: tokens.colors.text.default,
        boxShadow: tokens.shadows.md,
      }}
      className="card"
    >
      {children}
    </div>
  );
}

// Or with Tailwind
export function Card({ children }: { children: React.ReactNode }) {
  return (
    <div className="bg-background text-text border border-border shadow-md rounded-lg p-4">
      {children}
    </div>
  );
}

Testing Themes

// Test both themes
describe('Button', () => {
  it('renders in light theme', () => {
    render(
      <ThemeProvider theme="light">
        <Button>Click me</Button>
      </ThemeProvider>
    );
    
    const button = screen.getByRole('button');
    expect(button).toHaveStyle({ backgroundColor: lightTheme.colors.primary });
  });

  it('renders in dark theme', () => {
    render(
      <ThemeProvider theme="dark">
        <Button>Click me</Button>
      </ThemeProvider>
    );
    
    const button = screen.getByRole('button');
    expect(button).toHaveStyle({ backgroundColor: darkTheme.colors.primary });
  });
});

Best Practices

  1. CSS Variables: Use for runtime theme switching
  2. System Preference: Respect prefers-color-scheme
  3. Prevent Flash: Inline script before render
  4. localStorage: Persist user preference
  5. Type Safety: TypeScript theme types
  6. Token Reference: All colors from tokens
  7. Test Both: Test light and dark themes
  8. Smooth Transitions: Animate theme changes
  9. A11y: Maintain contrast ratios
  10. Documentation: Show all theme variants

Common Pitfalls

Hardcoded colors: Not theme-aware
Reference theme tokens

Flash of wrong theme: Bad UX
Inline script to prevent flash

No system preference: Force choice
Default to system preference

Poor contrast: Dark mode unreadable
Test contrast ratios

A flexible theming architecture lets users choose their experience—build it in from the start!

On this page