Theming Architecture for Multi-Brand Applications

A deep dive into building a flexible theming architecture that supports multiple brands, dark mode, and runtime customization using TypeScript and CSS custom properties.

technical9 min readBy Klivvr Engineering
Share:

When your design system serves a single product with a single brand, theming is straightforward — you hard-code your tokens and move on. But the moment you need to support dark mode, multiple brands, white-label customization, or high-contrast accessibility themes, you need a theming architecture that is flexible without being fragile. This article walks through the design and implementation of a theming system built on TypeScript and CSS custom properties, capable of supporting any number of themes at runtime.

The Architecture: Tokens, Themes, and Providers

The theming architecture has three layers. Tokens define the complete set of customizable values. Themes assign specific values to those tokens. Providers apply themes to the DOM and make them available to components.

Start by defining the token contract — the shape that every theme must satisfy:

// packages/tokens/src/theme-contract.ts
 
export interface ThemeContract {
  colors: {
    bg: {
      primary: string;
      secondary: string;
      tertiary: string;
      inverse: string;
      overlay: string;
    };
    text: {
      primary: string;
      secondary: string;
      tertiary: string;
      inverse: string;
      link: string;
    };
    border: {
      default: string;
      strong: string;
      focus: string;
    };
    accent: {
      primary: string;
      primaryHover: string;
      primaryActive: string;
      secondary: string;
    };
    feedback: {
      success: string;
      successBg: string;
      warning: string;
      warningBg: string;
      error: string;
      errorBg: string;
      info: string;
      infoBg: string;
    };
  };
  shadows: {
    sm: string;
    md: string;
    lg: string;
    xl: string;
  };
  radii: {
    sm: string;
    md: string;
    lg: string;
    full: string;
  };
}

This interface is the law. Every theme must provide every value. TypeScript enforces this at compile time — if a theme object is missing a property, it will not type-check.

Now define concrete themes that satisfy the contract:

// packages/themes/src/light.ts
import type { ThemeContract } from '@messier/tokens';
 
export const lightTheme: ThemeContract = {
  colors: {
    bg: {
      primary: '#FFFFFF',
      secondary: '#F9FAFB',
      tertiary: '#F3F4F6',
      inverse: '#111827',
      overlay: 'rgba(0, 0, 0, 0.5)',
    },
    text: {
      primary: '#111827',
      secondary: '#6B7280',
      tertiary: '#9CA3AF',
      inverse: '#FFFFFF',
      link: '#2563EB',
    },
    border: {
      default: '#E5E7EB',
      strong: '#D1D5DB',
      focus: '#2563EB',
    },
    accent: {
      primary: '#2563EB',
      primaryHover: '#1D4ED8',
      primaryActive: '#1E40AF',
      secondary: '#EFF6FF',
    },
    feedback: {
      success: '#16A34A',
      successBg: '#F0FDF4',
      warning: '#D97706',
      warningBg: '#FFFBEB',
      error: '#DC2626',
      errorBg: '#FEF2F2',
      info: '#2563EB',
      infoBg: '#EFF6FF',
    },
  },
  shadows: {
    sm: '0 1px 2px rgba(0, 0, 0, 0.05)',
    md: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
    lg: '0 10px 15px -3px rgba(0, 0, 0, 0.1)',
    xl: '0 20px 25px -5px rgba(0, 0, 0, 0.1)',
  },
  radii: {
    sm: '4px',
    md: '6px',
    lg: '8px',
    full: '9999px',
  },
};
// packages/themes/src/dark.ts
import type { ThemeContract } from '@messier/tokens';
 
export const darkTheme: ThemeContract = {
  colors: {
    bg: {
      primary: '#111827',
      secondary: '#1F2937',
      tertiary: '#374151',
      inverse: '#F9FAFB',
      overlay: 'rgba(0, 0, 0, 0.7)',
    },
    text: {
      primary: '#F9FAFB',
      secondary: '#D1D5DB',
      tertiary: '#9CA3AF',
      inverse: '#111827',
      link: '#60A5FA',
    },
    border: {
      default: '#374151',
      strong: '#4B5563',
      focus: '#60A5FA',
    },
    accent: {
      primary: '#3B82F6',
      primaryHover: '#60A5FA',
      primaryActive: '#93C5FD',
      secondary: '#1E3A8A',
    },
    feedback: {
      success: '#22C55E',
      successBg: '#052E16',
      warning: '#F59E0B',
      warningBg: '#451A03',
      error: '#EF4444',
      errorBg: '#450A0A',
      info: '#3B82F6',
      infoBg: '#172554',
    },
  },
  shadows: {
    sm: '0 1px 2px rgba(0, 0, 0, 0.3)',
    md: '0 4px 6px -1px rgba(0, 0, 0, 0.4)',
    lg: '0 10px 15px -3px rgba(0, 0, 0, 0.5)',
    xl: '0 20px 25px -5px rgba(0, 0, 0, 0.5)',
  },
  radii: {
    sm: '4px',
    md: '6px',
    lg: '8px',
    full: '9999px',
  },
};

Applying Themes with CSS Custom Properties

CSS custom properties are the mechanism that makes runtime theming possible. The ThemeProvider flattens the theme object into CSS variables and applies them to a DOM element:

// packages/core/src/ThemeProvider.tsx
import React, { createContext, useContext, useMemo, useEffect, useState } from 'react';
import type { ThemeContract } from '@messier/tokens';
import { lightTheme } from '@messier/themes';
 
interface ThemeContextValue {
  theme: ThemeContract;
  themeName: string;
  setTheme: (name: string, theme: ThemeContract) => void;
}
 
const ThemeContext = createContext<ThemeContextValue | null>(null);
 
function flattenTheme(
  obj: Record<string, any>,
  prefix = '-'
): Record<string, string> {
  const result: Record<string, string> = {};
 
  for (const [key, value] of Object.entries(obj)) {
    const cssVarName = `${prefix}-${key}`;
    if (typeof value === 'object' && value !== null) {
      Object.assign(result, flattenTheme(value, cssVarName));
    } else {
      result[cssVarName] = String(value);
    }
  }
 
  return result;
}
 
interface ThemeProviderProps {
  theme?: ThemeContract;
  themeName?: string;
  children: React.ReactNode;
  /** Apply to a specific element instead of :root */
  scope?: React.RefObject<HTMLElement>;
}
 
export function ThemeProvider({
  theme: initialTheme = lightTheme,
  themeName: initialName = 'light',
  children,
  scope,
}: ThemeProviderProps) {
  const [currentTheme, setCurrentTheme] = useState(initialTheme);
  const [currentName, setCurrentName] = useState(initialName);
 
  const cssVariables = useMemo(() => flattenTheme(currentTheme, '-messier'), [currentTheme]);
 
  useEffect(() => {
    const target = scope?.current ?? document.documentElement;
 
    for (const [property, value] of Object.entries(cssVariables)) {
      target.style.setProperty(property, value);
    }
 
    target.setAttribute('data-theme', currentName);
 
    return () => {
      for (const property of Object.keys(cssVariables)) {
        target.style.removeProperty(property);
      }
    };
  }, [cssVariables, currentName, scope]);
 
  const setTheme = (name: string, theme: ThemeContract) => {
    setCurrentName(name);
    setCurrentTheme(theme);
  };
 
  const contextValue = useMemo(
    () => ({ theme: currentTheme, themeName: currentName, setTheme }),
    [currentTheme, currentName]
  );
 
  return (
    <ThemeContext.Provider value={contextValue}>
      {children}
    </ThemeContext.Provider>
  );
}
 
export function useTheme(): ThemeContextValue {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error('useTheme must be used within a ThemeProvider');
  }
  return context;
}

Components reference CSS variables rather than token values directly. This makes them theme-agnostic by default:

/* Button styles reference theme variables */
.messier-button-primary {
  background-color: var(--messier-colors-accent-primary);
  color: var(--messier-colors-text-inverse);
  border-radius: var(--messier-radii-md);
  box-shadow: var(--messier-shadows-sm);
}
 
.messier-button-primary:hover {
  background-color: var(--messier-colors-accent-primaryHover);
}
 
.messier-card {
  background-color: var(--messier-colors-bg-primary);
  border: 1px solid var(--messier-colors-border-default);
  border-radius: var(--messier-radii-lg);
  box-shadow: var(--messier-shadows-md);
}

Multi-Brand Support

For white-label or multi-brand applications, themes extend beyond color. Brand A might use rounded corners and a blue accent; Brand B might use sharp corners and green. The theme contract already supports this through its radii and accent tokens.

Create brand-specific theme factories:

// packages/themes/src/create-brand-theme.ts
import type { ThemeContract } from '@messier/tokens';
import { lightTheme } from './light';
import { darkTheme } from './dark';
 
interface BrandConfig {
  name: string;
  primaryColor: string;
  primaryHoverColor: string;
  primaryActiveColor: string;
  secondaryColor: string;
  borderRadius: 'sharp' | 'rounded' | 'pill';
  fontFamily?: string;
}
 
const radiusPresets = {
  sharp: { sm: '2px', md: '3px', lg: '4px', full: '9999px' },
  rounded: { sm: '4px', md: '6px', lg: '8px', full: '9999px' },
  pill: { sm: '8px', md: '12px', lg: '16px', full: '9999px' },
};
 
export function createBrandTheme(config: BrandConfig): { light: ThemeContract; dark: ThemeContract } {
  const accentOverrides = {
    primary: config.primaryColor,
    primaryHover: config.primaryHoverColor,
    primaryActive: config.primaryActiveColor,
    secondary: config.secondaryColor,
  };
 
  const radiiOverrides = radiusPresets[config.borderRadius];
 
  return {
    light: {
      ...lightTheme,
      colors: {
        ...lightTheme.colors,
        accent: accentOverrides,
        text: {
          ...lightTheme.colors.text,
          link: config.primaryColor,
        },
        border: {
          ...lightTheme.colors.border,
          focus: config.primaryColor,
        },
      },
      radii: radiiOverrides,
    },
    dark: {
      ...darkTheme,
      colors: {
        ...darkTheme.colors,
        accent: accentOverrides,
        text: {
          ...darkTheme.colors.text,
          link: config.primaryHoverColor,
        },
        border: {
          ...darkTheme.colors.border,
          focus: config.primaryHoverColor,
        },
      },
      radii: radiiOverrides,
    },
  };
}
 
// Usage
const acmeBrand = createBrandTheme({
  name: 'Acme',
  primaryColor: '#059669',
  primaryHoverColor: '#047857',
  primaryActiveColor: '#065F46',
  secondaryColor: '#ECFDF5',
  borderRadius: 'pill',
});
 
const globexBrand = createBrandTheme({
  name: 'Globex',
  primaryColor: '#7C3AED',
  primaryHoverColor: '#6D28D9',
  primaryActiveColor: '#5B21B6',
  secondaryColor: '#F5F3FF',
  borderRadius: 'sharp',
});

Applications configure their brand at the root level:

import { ThemeProvider } from '@messier/core';
 
// The brand theme is loaded from configuration
const brandThemes = createBrandTheme(appConfig.brand);
 
function App() {
  const [mode, setMode] = useState<'light' | 'dark'>('light');
  const theme = mode === 'light' ? brandThemes.light : brandThemes.dark;
 
  return (
    <ThemeProvider theme={theme} themeName={`${appConfig.brand.name}-${mode}`}>
      <AppShell />
    </ThemeProvider>
  );
}

Scoped Themes for Embedded Contexts

Sometimes you need different themes within the same page — a dark sidebar alongside a light content area, or an embedded widget with its own brand. Scoped themes solve this by applying CSS variables to a specific DOM subtree rather than the root.

import { ThemeProvider, useTheme } from '@messier/core';
import { darkTheme } from '@messier/themes';
 
function DashboardLayout() {
  const sidebarRef = useRef<HTMLDivElement>(null);
 
  return (
    <div className="dashboard-layout">
      {/* Sidebar uses dark theme */}
      <ThemeProvider theme={darkTheme} themeName="dark" scope={sidebarRef}>
        <aside ref={sidebarRef} className="sidebar">
          <Navigation />
        </aside>
      </ThemeProvider>
 
      {/* Main content inherits the parent theme */}
      <main className="content">
        <PageContent />
      </main>
    </div>
  );
}

Because CSS custom properties cascade through the DOM, the sidebar's children inherit the dark theme variables while the main content inherits the root theme. No class name toggling, no duplicate stylesheets.

For nested themes to work correctly, components must always reference CSS variables rather than theme context values directly. A component that reads useTheme().theme.colors.accent.primary will always get the nearest React context value, which may not match the CSS variable scope. The rule is simple: use CSS variables for visual styling, use the theme context only for logic (like choosing between light and dark icons).

System Preference Detection and Persistence

Users expect applications to respect their operating system preferences. Detect the system color scheme and persist user overrides:

// packages/core/src/useColorScheme.ts
import { useState, useEffect, useCallback } from 'react';
 
type ColorScheme = 'light' | 'dark' | 'system';
 
const STORAGE_KEY = 'messier-color-scheme';
 
function getSystemScheme(): 'light' | 'dark' {
  if (typeof window === 'undefined') return 'light';
  return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
 
export function useColorScheme() {
  const [preference, setPreference] = useState<ColorScheme>(() => {
    if (typeof window === 'undefined') return 'system';
    return (localStorage.getItem(STORAGE_KEY) as ColorScheme) || 'system';
  });
 
  const [resolved, setResolved] = useState<'light' | 'dark'>(() => {
    return preference === 'system' ? getSystemScheme() : preference;
  });
 
  useEffect(() => {
    if (preference !== 'system') {
      setResolved(preference);
      return;
    }
 
    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
    const handler = (e: MediaQueryListEvent) => {
      setResolved(e.matches ? 'dark' : 'light');
    };
 
    setResolved(mediaQuery.matches ? 'dark' : 'light');
    mediaQuery.addEventListener('change', handler);
    return () => mediaQuery.removeEventListener('change', handler);
  }, [preference]);
 
  const setColorScheme = useCallback((scheme: ColorScheme) => {
    setPreference(scheme);
    localStorage.setItem(STORAGE_KEY, scheme);
  }, []);
 
  return {
    preference,       // 'light' | 'dark' | 'system'
    resolved,         // 'light' | 'dark' (the actual applied scheme)
    setColorScheme,   // function to update the preference
  };
}

Wire this into the ThemeProvider at the application root:

function App() {
  const { resolved, setColorScheme } = useColorScheme();
  const theme = resolved === 'dark' ? darkTheme : lightTheme;
 
  return (
    <ThemeProvider theme={theme} themeName={resolved}>
      <AppShell onThemeToggle={() => setColorScheme(resolved === 'dark' ? 'light' : 'dark')} />
    </ThemeProvider>
  );
}

Conclusion

A well-designed theming architecture turns a rigid component library into a flexible platform. The key elements are a strict theme contract enforced by TypeScript, CSS custom properties for runtime application, brand factories for multi-tenant scenarios, scoped providers for mixed-theme layouts, and system preference detection for user respect. Build this foundation once, and your design system can serve any product, any brand, and any user preference without component-level changes.

Related Articles

technical

Versioning and Publishing Component Libraries

A practical guide to versioning, releasing, and distributing TypeScript component libraries using semantic versioning, changesets, and automated CI/CD publishing pipelines.

10 min read
business

Measuring the ROI of a Design System

A practical framework for measuring the return on investment of a design system, covering efficiency metrics, quality improvements, consistency gains, and how to communicate value to stakeholders.

10 min read
business

Documentation That Developers Actually Read

How to create design system documentation that developers engage with, covering information architecture, interactive examples, API references, and documentation-as-code workflows.

12 min read