Widget Theme System Implementation for ChatGPT Apps

Building a flexible, performant theming system is critical for ChatGPT widget development. Users expect seamless brand integration, dark mode support, and personalized color schemes—all while maintaining WCAG AA accessibility standards and sub-50ms theme switching performance.

A robust theme system enables white-label customization, user preference persistence, and dynamic color generation. Whether you're building a single branded widget or a multi-tenant platform serving thousands of organizations, your theming architecture determines scalability and user satisfaction.

This guide covers production-ready theming patterns using CSS custom properties, React Context API, color science algorithms, and performance optimization techniques. You'll learn to build theme systems that handle real-time switching, server-side rendering, and automated accessibility validation.

CSS Custom Properties Foundation

CSS custom properties (CSS variables) provide the foundation for modern theming systems. Unlike preprocessor variables, CSS custom properties are dynamic, cascade through the DOM, and can be updated at runtime without recompiling stylesheets.

Theme Token Architecture

Structure your theme tokens in hierarchical layers: primitives (raw color values), semantic tokens (purpose-based mappings), and component tokens (specific UI element styles).

/* Theme Token System - 142 lines */
:root {
  /* === Primitive Color Palette === */
  --color-blue-50: #eff6ff;
  --color-blue-100: #dbeafe;
  --color-blue-200: #bfdbfe;
  --color-blue-300: #93c5fd;
  --color-blue-400: #60a5fa;
  --color-blue-500: #3b82f6;
  --color-blue-600: #2563eb;
  --color-blue-700: #1d4ed8;
  --color-blue-800: #1e40af;
  --color-blue-900: #1e3a8a;

  --color-gray-50: #f9fafb;
  --color-gray-100: #f3f4f6;
  --color-gray-200: #e5e7eb;
  --color-gray-300: #d1d5db;
  --color-gray-400: #9ca3af;
  --color-gray-500: #6b7280;
  --color-gray-600: #4b5563;
  --color-gray-700: #374151;
  --color-gray-800: #1f2937;
  --color-gray-900: #111827;

  --color-green-50: #f0fdf4;
  --color-green-500: #22c55e;
  --color-green-700: #15803d;

  --color-red-50: #fef2f2;
  --color-red-500: #ef4444;
  --color-red-700: #b91c1c;

  --color-amber-50: #fffbeb;
  --color-amber-500: #f59e0b;
  --color-amber-700: #b45309;

  /* === Semantic Tokens (Light Mode Default) === */
  --color-primary: var(--color-blue-600);
  --color-primary-hover: var(--color-blue-700);
  --color-primary-active: var(--color-blue-800);
  --color-primary-subtle: var(--color-blue-50);

  --color-text-primary: var(--color-gray-900);
  --color-text-secondary: var(--color-gray-600);
  --color-text-tertiary: var(--color-gray-500);
  --color-text-inverse: var(--color-gray-50);

  --color-bg-primary: #ffffff;
  --color-bg-secondary: var(--color-gray-50);
  --color-bg-tertiary: var(--color-gray-100);
  --color-bg-inverse: var(--color-gray-900);

  --color-border-default: var(--color-gray-200);
  --color-border-medium: var(--color-gray-300);
  --color-border-strong: var(--color-gray-400);

  --color-success: var(--color-green-500);
  --color-success-bg: var(--color-green-50);
  --color-error: var(--color-red-500);
  --color-error-bg: var(--color-red-50);
  --color-warning: var(--color-amber-500);
  --color-warning-bg: var(--color-amber-50);

  /* === Spacing Scale === */
  --spacing-1: 0.25rem;  /* 4px */
  --spacing-2: 0.5rem;   /* 8px */
  --spacing-3: 0.75rem;  /* 12px */
  --spacing-4: 1rem;     /* 16px */
  --spacing-5: 1.25rem;  /* 20px */
  --spacing-6: 1.5rem;   /* 24px */
  --spacing-8: 2rem;     /* 32px */
  --spacing-10: 2.5rem;  /* 40px */
  --spacing-12: 3rem;    /* 48px */

  /* === Typography Scale === */
  --font-size-xs: 0.75rem;    /* 12px */
  --font-size-sm: 0.875rem;   /* 14px */
  --font-size-base: 1rem;     /* 16px */
  --font-size-lg: 1.125rem;   /* 18px */
  --font-size-xl: 1.25rem;    /* 20px */
  --font-size-2xl: 1.5rem;    /* 24px */
  --font-size-3xl: 1.875rem;  /* 30px */

  --font-weight-normal: 400;
  --font-weight-medium: 500;
  --font-weight-semibold: 600;
  --font-weight-bold: 700;

  --line-height-tight: 1.25;
  --line-height-normal: 1.5;
  --line-height-relaxed: 1.75;

  /* === Border Radius === */
  --radius-sm: 0.25rem;  /* 4px */
  --radius-md: 0.375rem; /* 6px */
  --radius-lg: 0.5rem;   /* 8px */
  --radius-xl: 0.75rem;  /* 12px */
  --radius-full: 9999px;

  /* === Shadows === */
  --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
  --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
  --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
  --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);

  /* === Transitions === */
  --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
  --transition-base: 200ms cubic-bezier(0.4, 0, 0.2, 1);
  --transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1);
}

/* === Dark Mode Theme === */
[data-theme="dark"] {
  --color-primary: var(--color-blue-400);
  --color-primary-hover: var(--color-blue-300);
  --color-primary-active: var(--color-blue-200);
  --color-primary-subtle: var(--color-blue-900);

  --color-text-primary: var(--color-gray-50);
  --color-text-secondary: var(--color-gray-300);
  --color-text-tertiary: var(--color-gray-400);
  --color-text-inverse: var(--color-gray-900);

  --color-bg-primary: #0f1419;
  --color-bg-secondary: var(--color-gray-900);
  --color-bg-tertiary: var(--color-gray-800);
  --color-bg-inverse: var(--color-gray-50);

  --color-border-default: var(--color-gray-700);
  --color-border-medium: var(--color-gray-600);
  --color-border-strong: var(--color-gray-500);

  --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
  --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.3);
  --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.5), 0 4px 6px -4px rgb(0 0 0 / 0.4);
}

/* === Component Tokens === */
.btn-primary {
  background-color: var(--color-primary);
  color: var(--color-text-inverse);
  padding: var(--spacing-3) var(--spacing-6);
  border-radius: var(--radius-lg);
  font-weight: var(--font-weight-semibold);
  transition: background-color var(--transition-fast);
}

.btn-primary:hover {
  background-color: var(--color-primary-hover);
}

.card {
  background-color: var(--color-bg-primary);
  border: 1px solid var(--color-border-default);
  border-radius: var(--radius-xl);
  padding: var(--spacing-6);
  box-shadow: var(--shadow-sm);
}

This token system separates concerns: primitives never change across themes, semantic tokens map to primitives based on theme mode, and component tokens reference semantic tokens for consistent styling.

Theme Provider Pattern

React Context API provides the ideal mechanism for theme distribution across component trees. A well-designed theme provider handles theme switching, persistence, SSR hydration, and real-time updates.

Production Theme Provider

// ThemeProvider.tsx - 158 lines
import React, {
  createContext,
  useContext,
  useState,
  useEffect,
  useMemo,
  useCallback,
  ReactNode
} from 'react';

// Theme type definitions
export type ThemeMode = 'light' | 'dark' | 'auto';

export interface ThemeColors {
  primary: string;
  primaryHover: string;
  primaryActive: string;
  textPrimary: string;
  textSecondary: string;
  bgPrimary: string;
  bgSecondary: string;
  borderDefault: string;
}

export interface Theme {
  mode: ThemeMode;
  colors: ThemeColors;
  customBranding?: {
    logo?: string;
    fontFamily?: string;
    borderRadius?: string;
  };
}

interface ThemeContextValue {
  theme: Theme;
  effectiveMode: 'light' | 'dark';
  setThemeMode: (mode: ThemeMode) => void;
  updateColors: (colors: Partial<ThemeColors>) => void;
  applyBranding: (branding: Theme['customBranding']) => void;
  resetTheme: () => void;
}

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

// System preference detection
function getSystemPreference(): 'light' | 'dark' {
  if (typeof window === 'undefined') return 'light';
  return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}

// Load theme from localStorage
function loadStoredTheme(): Theme | null {
  if (typeof window === 'undefined') return null;
  try {
    const stored = localStorage.getItem('chatgpt-widget-theme');
    return stored ? JSON.parse(stored) : null;
  } catch {
    return null;
  }
}

// Save theme to localStorage
function saveTheme(theme: Theme): void {
  if (typeof window === 'undefined') return;
  try {
    localStorage.setItem('chatgpt-widget-theme', JSON.stringify(theme));
  } catch (error) {
    console.warn('Failed to save theme:', error);
  }
}

// Default theme configuration
const DEFAULT_LIGHT_COLORS: ThemeColors = {
  primary: '#2563eb',
  primaryHover: '#1d4ed8',
  primaryActive: '#1e40af',
  textPrimary: '#111827',
  textSecondary: '#4b5563',
  bgPrimary: '#ffffff',
  bgSecondary: '#f9fafb',
  borderDefault: '#e5e7eb'
};

const DEFAULT_DARK_COLORS: ThemeColors = {
  primary: '#60a5fa',
  primaryHover: '#93c5fd',
  primaryActive: '#bfdbfe',
  textPrimary: '#f9fafb',
  textSecondary: '#d1d5db',
  bgPrimary: '#0f1419',
  bgSecondary: '#111827',
  borderDefault: '#374151'
};

interface ThemeProviderProps {
  children: ReactNode;
  defaultMode?: ThemeMode;
  disableTransitions?: boolean;
}

export function ThemeProvider({
  children,
  defaultMode = 'auto',
  disableTransitions = false
}: ThemeProviderProps) {
  const [systemPreference, setSystemPreference] = useState<'light' | 'dark'>(() =>
    getSystemPreference()
  );

  const [theme, setTheme] = useState<Theme>(() => {
    const stored = loadStoredTheme();
    if (stored) return stored;
    return {
      mode: defaultMode,
      colors: DEFAULT_LIGHT_COLORS
    };
  });

  // Calculate effective mode (resolve 'auto' to actual mode)
  const effectiveMode = useMemo<'light' | 'dark'>(() => {
    if (theme.mode === 'auto') return systemPreference;
    return theme.mode;
  }, [theme.mode, systemPreference]);

  // Listen for system preference changes
  useEffect(() => {
    if (typeof window === 'undefined') return;

    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
    const handler = (e: MediaQueryListEvent) => {
      setSystemPreference(e.matches ? 'dark' : 'light');
    };

    mediaQuery.addEventListener('change', handler);
    return () => mediaQuery.removeEventListener('change', handler);
  }, []);

  // Apply theme to DOM
  useEffect(() => {
    const root = document.documentElement;

    // Disable transitions during theme switch
    if (disableTransitions) {
      root.style.setProperty('--transition-fast', '0ms');
      root.style.setProperty('--transition-base', '0ms');
      root.style.setProperty('--transition-slow', '0ms');
    }

    root.setAttribute('data-theme', effectiveMode);

    // Apply custom colors
    const colors = theme.colors;
    Object.entries(colors).forEach(([key, value]) => {
      const cssVar = `--color-${key.replace(/([A-Z])/g, '-$1').toLowerCase()}`;
      root.style.setProperty(cssVar, value);
    });

    // Apply custom branding
    if (theme.customBranding) {
      if (theme.customBranding.fontFamily) {
        root.style.setProperty('--font-family-base', theme.customBranding.fontFamily);
      }
      if (theme.customBranding.borderRadius) {
        root.style.setProperty('--radius-md', theme.customBranding.borderRadius);
      }
    }

    // Re-enable transitions
    if (disableTransitions) {
      setTimeout(() => {
        root.style.removeProperty('--transition-fast');
        root.style.removeProperty('--transition-base');
        root.style.removeProperty('--transition-slow');
      }, 0);
    }
  }, [effectiveMode, theme, disableTransitions]);

  // Theme update handlers
  const setThemeMode = useCallback((mode: ThemeMode) => {
    setTheme(prev => {
      const updated = { ...prev, mode };
      saveTheme(updated);
      return updated;
    });
  }, []);

  const updateColors = useCallback((colors: Partial<ThemeColors>) => {
    setTheme(prev => {
      const updated = {
        ...prev,
        colors: { ...prev.colors, ...colors }
      };
      saveTheme(updated);
      return updated;
    });
  }, []);

  const applyBranding = useCallback((branding: Theme['customBranding']) => {
    setTheme(prev => {
      const updated = { ...prev, customBranding: branding };
      saveTheme(updated);
      return updated;
    });
  }, []);

  const resetTheme = useCallback(() => {
    const defaultTheme: Theme = {
      mode: 'auto',
      colors: effectiveMode === 'dark' ? DEFAULT_DARK_COLORS : DEFAULT_LIGHT_COLORS
    };
    setTheme(defaultTheme);
    saveTheme(defaultTheme);
  }, [effectiveMode]);

  const contextValue = useMemo<ThemeContextValue>(
    () => ({
      theme,
      effectiveMode,
      setThemeMode,
      updateColors,
      applyBranding,
      resetTheme
    }),
    [theme, effectiveMode, setThemeMode, updateColors, applyBranding, resetTheme]
  );

  return <ThemeContext.Provider value={contextValue}>{children}</ThemeContext.Provider>;
}

// Custom hook for consuming theme
export function useTheme(): ThemeContextValue {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error('useTheme must be used within ThemeProvider');
  }
  return context;
}

Theme Switcher Hook

A dedicated hook encapsulates theme switching logic, providing a clean API for UI components.

// useThemeSwitcher.ts - 124 lines
import { useCallback, useMemo } from 'react';
import { useTheme } from './ThemeProvider';
import type { ThemeMode } from './ThemeProvider';

export interface ThemeSwitcherOptions {
  includeSystemOption?: boolean;
  animationDuration?: number;
  onThemeChange?: (mode: ThemeMode) => void;
}

export interface ThemeSwitcherResult {
  currentMode: ThemeMode;
  effectiveMode: 'light' | 'dark';
  isSystemPreference: boolean;
  switchToLight: () => void;
  switchToDark: () => void;
  switchToAuto: () => void;
  toggleTheme: () => void;
  cycleTheme: () => void;
  availableModes: ThemeMode[];
}

export function useThemeSwitcher(
  options: ThemeSwitcherOptions = {}
): ThemeSwitcherResult {
  const {
    includeSystemOption = true,
    animationDuration = 200,
    onThemeChange
  } = options;

  const { theme, effectiveMode, setThemeMode } = useTheme();

  const isSystemPreference = useMemo(
    () => theme.mode === 'auto',
    [theme.mode]
  );

  const availableModes = useMemo<ThemeMode[]>(() => {
    const modes: ThemeMode[] = ['light', 'dark'];
    if (includeSystemOption) modes.push('auto');
    return modes;
  }, [includeSystemOption]);

  // Handle theme change with animation and callback
  const handleThemeChange = useCallback(
    (mode: ThemeMode) => {
      // Trigger transition animation
      if (animationDuration > 0) {
        document.documentElement.style.setProperty(
          '--theme-transition-duration',
          `${animationDuration}ms`
        );
      }

      setThemeMode(mode);

      // Execute callback
      if (onThemeChange) {
        setTimeout(() => onThemeChange(mode), animationDuration);
      }

      // Analytics tracking
      if (typeof window !== 'undefined' && (window as any).gtag) {
        (window as any).gtag('event', 'theme_change', {
          theme_mode: mode,
          effective_mode: effectiveMode
        });
      }
    },
    [setThemeMode, onThemeChange, animationDuration, effectiveMode]
  );

  const switchToLight = useCallback(() => {
    handleThemeChange('light');
  }, [handleThemeChange]);

  const switchToDark = useCallback(() => {
    handleThemeChange('dark');
  }, [handleThemeChange]);

  const switchToAuto = useCallback(() => {
    if (includeSystemOption) {
      handleThemeChange('auto');
    }
  }, [handleThemeChange, includeSystemOption]);

  // Toggle between light and dark (ignores auto)
  const toggleTheme = useCallback(() => {
    const newMode = effectiveMode === 'light' ? 'dark' : 'light';
    handleThemeChange(newMode);
  }, [effectiveMode, handleThemeChange]);

  // Cycle through all available modes
  const cycleTheme = useCallback(() => {
    const currentIndex = availableModes.indexOf(theme.mode);
    const nextIndex = (currentIndex + 1) % availableModes.length;
    const nextMode = availableModes[nextIndex];
    handleThemeChange(nextMode);
  }, [theme.mode, availableModes, handleThemeChange]);

  return {
    currentMode: theme.mode,
    effectiveMode,
    isSystemPreference,
    switchToLight,
    switchToDark,
    switchToAuto,
    toggleTheme,
    cycleTheme,
    availableModes
  };
}

// Example UI component using the hook
export function ThemeSwitcher() {
  const {
    effectiveMode,
    isSystemPreference,
    cycleTheme,
    availableModes
  } = useThemeSwitcher({
    includeSystemOption: true,
    animationDuration: 300,
    onThemeChange: (mode) => {
      console.log(`Theme switched to: ${mode}`);
    }
  });

  return (
    <button
      onClick={cycleTheme}
      className="theme-switcher"
      aria-label={`Current theme: ${effectiveMode}${isSystemPreference ? ' (auto)' : ''}`}
      title="Cycle theme"
    >
      {effectiveMode === 'dark' ? '🌙' : '☀️'}
      {isSystemPreference && <span className="auto-badge">Auto</span>}
    </button>
  );
}

Dynamic Theme Generation

Advanced theming systems generate color palettes algorithmically from a single brand color, ensuring consistent contrast ratios and accessibility compliance.

Color Palette Generator

// colorPaletteGenerator.ts - 135 lines
import { hexToRgb, rgbToHsl, hslToRgb, rgbToHex } from './colorUtils';

export interface ColorPalette {
  50: string;
  100: string;
  200: string;
  300: string;
  400: string;
  500: string; // base color
  600: string;
  700: string;
  800: string;
  900: string;
}

export interface GeneratePaletteOptions {
  baseColor: string; // hex color (e.g., '#2563eb')
  saturationCurve?: 'linear' | 'ease-in' | 'ease-out';
  lightnessCurve?: 'linear' | 'ease-in' | 'ease-out';
}

/**
 * Generates a 10-shade color palette from a base color
 * Following Tailwind CSS palette generation principles
 */
export function generateColorPalette(
  options: GeneratePaletteOptions
): ColorPalette {
  const { baseColor, saturationCurve = 'ease-out', lightnessCurve = 'ease-in' } = options;

  const baseRgb = hexToRgb(baseColor);
  const baseHsl = rgbToHsl(baseRgb.r, baseRgb.g, baseRgb.b);

  const palette: Partial<ColorPalette> = {
    500: baseColor // Base color at 500
  };

  // Define lightness values for each shade
  const lightnessSteps = [95, 90, 80, 70, 60, 50, 40, 30, 20, 10];
  const shades = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900] as const;

  shades.forEach((shade, index) => {
    if (shade === 500) return; // Already set

    let lightness = lightnessSteps[index];
    let saturation = baseHsl.s;

    // Apply lightness curve
    if (lightnessCurve === 'ease-in') {
      const normalizedIndex = index / (shades.length - 1);
      const eased = normalizedIndex * normalizedIndex;
      lightness = 95 - (eased * 85);
    } else if (lightnessCurve === 'ease-out') {
      const normalizedIndex = index / (shades.length - 1);
      const eased = 1 - Math.pow(1 - normalizedIndex, 2);
      lightness = 95 - (eased * 85);
    }

    // Apply saturation curve (reduce saturation at extremes)
    if (saturationCurve === 'ease-in') {
      if (shade < 500) {
        saturation = baseHsl.s * (0.3 + (index / shades.indexOf(500)) * 0.7);
      } else if (shade > 500) {
        const stepsFromBase = index - shades.indexOf(500);
        const totalSteps = shades.length - shades.indexOf(500) - 1;
        saturation = baseHsl.s * (1 - (stepsFromBase / totalSteps) * 0.4);
      }
    } else if (saturationCurve === 'ease-out') {
      if (shade < 500) {
        const normalizedPos = index / shades.indexOf(500);
        saturation = baseHsl.s * (0.5 + normalizedPos * 0.5);
      } else if (shade > 500) {
        const stepsFromBase = index - shades.indexOf(500);
        const totalSteps = shades.length - shades.indexOf(500) - 1;
        const normalizedPos = stepsFromBase / totalSteps;
        saturation = baseHsl.s * (1 - Math.pow(normalizedPos, 2) * 0.3);
      }
    }

    const rgb = hslToRgb(baseHsl.h, saturation, lightness);
    palette[shade] = rgbToHex(rgb.r, rgb.g, rgb.b);
  });

  return palette as ColorPalette;
}

/**
 * Generate semantic color tokens from palette
 */
export interface SemanticColors {
  primary: string;
  primaryHover: string;
  primaryActive: string;
  textPrimary: string;
  textSecondary: string;
  bgPrimary: string;
  bgSecondary: string;
  borderDefault: string;
}

export function generateSemanticColors(
  palette: ColorPalette,
  mode: 'light' | 'dark'
): SemanticColors {
  if (mode === 'light') {
    return {
      primary: palette[600],
      primaryHover: palette[700],
      primaryActive: palette[800],
      textPrimary: '#111827',
      textSecondary: '#4b5563',
      bgPrimary: '#ffffff',
      bgSecondary: '#f9fafb',
      borderDefault: '#e5e7eb'
    };
  } else {
    return {
      primary: palette[400],
      primaryHover: palette[300],
      primaryActive: palette[200],
      textPrimary: '#f9fafb',
      textSecondary: '#d1d5db',
      bgPrimary: '#0f1419',
      bgSecondary: '#111827',
      borderDefault: '#374151'
    };
  }
}

// Color utility functions (simplified)
export const colorUtils = {
  hexToRgb(hex: string): { r: number; g: number; b: number } {
    const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
    return result ? {
      r: parseInt(result[1], 16),
      g: parseInt(result[2], 16),
      b: parseInt(result[3], 16)
    } : { r: 0, g: 0, b: 0 };
  },

  rgbToHex(r: number, g: number, b: number): string {
    return '#' + [r, g, b].map(x => {
      const hex = Math.round(x).toString(16);
      return hex.length === 1 ? '0' + hex : hex;
    }).join('');
  },

  rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } {
    r /= 255;
    g /= 255;
    b /= 255;
    const max = Math.max(r, g, b), min = Math.min(r, g, b);
    let h = 0, s = 0;
    const l = (max + min) / 2;

    if (max !== min) {
      const d = max - min;
      s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
      switch (max) {
        case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
        case g: h = ((b - r) / d + 2) / 6; break;
        case b: h = ((r - g) / d + 4) / 6; break;
      }
    }

    return { h: h * 360, s: s * 100, l: l * 100 };
  },

  hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } {
    h /= 360;
    s /= 100;
    l /= 100;
    let r, g, b;

    if (s === 0) {
      r = g = b = l;
    } else {
      const hue2rgb = (p: number, q: number, t: number) => {
        if (t < 0) t += 1;
        if (t > 1) t -= 1;
        if (t < 1/6) return p + (q - p) * 6 * t;
        if (t < 1/2) return q;
        if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
        return p;
      };
      const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
      const p = 2 * l - q;
      r = hue2rgb(p, q, h + 1/3);
      g = hue2rgb(p, q, h);
      b = hue2rgb(p, q, h - 1/3);
    }

    return { r: r * 255, g: g * 255, b: b * 255 };
  }
};

// Re-export for convenience
export const { hexToRgb, rgbToHex, rgbToHsl, hslToRgb } = colorUtils;

Contrast Validation

// contrastValidator.ts - 102 lines
export type WCAGLevel = 'AA' | 'AAA';
export type TextSize = 'normal' | 'large'; // large: 18pt+ or 14pt+ bold

export interface ContrastResult {
  ratio: number;
  passes: {
    AA: { normal: boolean; large: boolean };
    AAA: { normal: boolean; large: boolean };
  };
  recommendation?: string;
}

/**
 * Calculate relative luminance (WCAG formula)
 * https://www.w3.org/TR/WCAG20/#relativeluminancedef
 */
function getLuminance(r: number, g: number, b: number): number {
  const [rs, gs, bs] = [r, g, b].map(val => {
    const sRGB = val / 255;
    return sRGB <= 0.03928
      ? sRGB / 12.92
      : Math.pow((sRGB + 0.055) / 1.055, 2.4);
  });
  return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
}

/**
 * Calculate contrast ratio between two colors
 * https://www.w3.org/TR/WCAG20/#contrast-ratiodef
 */
export function getContrastRatio(
  foreground: string,
  background: string
): number {
  const hexToRgb = (hex: string): { r: number; g: number; b: number } => {
    const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
    return result ? {
      r: parseInt(result[1], 16),
      g: parseInt(result[2], 16),
      b: parseInt(result[3], 16)
    } : { r: 0, g: 0, b: 0 };
  };

  const fg = hexToRgb(foreground);
  const bg = hexToRgb(background);

  const lum1 = getLuminance(fg.r, fg.g, fg.b);
  const lum2 = getLuminance(bg.r, bg.g, bg.b);

  const lighter = Math.max(lum1, lum2);
  const darker = Math.min(lum1, lum2);

  return (lighter + 0.05) / (darker + 0.05);
}

/**
 * Validate color combination against WCAG standards
 */
export function validateContrast(
  foreground: string,
  background: string
): ContrastResult {
  const ratio = getContrastRatio(foreground, background);

  // WCAG requirements:
  // AA: 4.5:1 (normal text), 3:1 (large text)
  // AAA: 7:1 (normal text), 4.5:1 (large text)
  const result: ContrastResult = {
    ratio,
    passes: {
      AA: {
        normal: ratio >= 4.5,
        large: ratio >= 3
      },
      AAA: {
        normal: ratio >= 7,
        large: ratio >= 4.5
      }
    }
  };

  // Generate recommendation if AA normal fails
  if (!result.passes.AA.normal) {
    if (ratio >= 3) {
      result.recommendation = 'Use larger text (18pt+ or 14pt+ bold) to meet AA standards';
    } else {
      result.recommendation = 'Increase contrast - current ratio too low for accessibility';
    }
  }

  return result;
}

/**
 * Find accessible color from palette that meets WCAG standard
 */
export function findAccessibleColor(
  background: string,
  palette: string[],
  level: WCAGLevel = 'AA',
  textSize: TextSize = 'normal'
): string | null {
  const requiredRatio = level === 'AAA'
    ? (textSize === 'large' ? 4.5 : 7)
    : (textSize === 'large' ? 3 : 4.5);

  for (const color of palette) {
    const ratio = getContrastRatio(color, background);
    if (ratio >= requiredRatio) {
      return color;
    }
  }

  return null;
}

Brand Customization

White-label theming requires flexible brand customization that overrides default styling while maintaining accessibility and performance.

Brand Customization Manager

// brandCustomizationManager.ts - 118 lines
import { generateColorPalette, generateSemanticColors } from './colorPaletteGenerator';
import { validateContrast } from './contrastValidator';
import type { ThemeColors } from './ThemeProvider';

export interface BrandConfig {
  primaryColor: string;
  logo?: string;
  logoUrl?: string;
  fontFamily?: string;
  borderRadius?: 'sharp' | 'rounded' | 'pill';
  companyName?: string;
}

export interface BrandTheme {
  colors: {
    light: ThemeColors;
    dark: ThemeColors;
  };
  branding: {
    logo?: string;
    fontFamily?: string;
    borderRadius?: string;
  };
  validation: {
    contrastIssues: Array<{ context: string; ratio: number; message: string }>;
    warnings: string[];
  };
}

/**
 * Generate complete brand theme from brand config
 */
export function generateBrandTheme(config: BrandConfig): BrandTheme {
  const { primaryColor, logo, logoUrl, fontFamily, borderRadius, companyName } = config;

  // Generate color palette from primary color
  const palette = generateColorPalette({
    baseColor: primaryColor,
    saturationCurve: 'ease-out',
    lightnessCurve: 'ease-in'
  });

  // Generate semantic colors for both modes
  const lightColors = generateSemanticColors(palette, 'light');
  const darkColors = generateSemanticColors(palette, 'dark');

  // Validate contrast ratios
  const validation = validateBrandTheme({ light: lightColors, dark: darkColors });

  // Convert border radius setting
  let radiusValue: string | undefined;
  switch (borderRadius) {
    case 'sharp':
      radiusValue = '0px';
      break;
    case 'rounded':
      radiusValue = '0.5rem';
      break;
    case 'pill':
      radiusValue = '9999px';
      break;
  }

  return {
    colors: {
      light: lightColors,
      dark: darkColors
    },
    branding: {
      logo: logoUrl || logo,
      fontFamily: fontFamily ? `${fontFamily}, -apple-system, sans-serif` : undefined,
      borderRadius: radiusValue
    },
    validation
  };
}

/**
 * Validate brand theme for accessibility issues
 */
function validateBrandTheme(colors: { light: ThemeColors; dark: ThemeColors }) {
  const contrastIssues: Array<{ context: string; ratio: number; message: string }> = [];
  const warnings: string[] = [];

  // Check light mode contrasts
  const lightPrimaryBg = validateContrast(colors.light.primary, colors.light.bgPrimary);
  if (!lightPrimaryBg.passes.AA.normal) {
    contrastIssues.push({
      context: 'Light mode: Primary button on background',
      ratio: lightPrimaryBg.ratio,
      message: lightPrimaryBg.recommendation || 'Insufficient contrast'
    });
  }

  const lightTextBg = validateContrast(colors.light.textPrimary, colors.light.bgPrimary);
  if (!lightTextBg.passes.AA.normal) {
    contrastIssues.push({
      context: 'Light mode: Text on background',
      ratio: lightTextBg.ratio,
      message: 'Text color does not meet AA standards on primary background'
    });
  }

  // Check dark mode contrasts
  const darkPrimaryBg = validateContrast(colors.dark.primary, colors.dark.bgPrimary);
  if (!darkPrimaryBg.passes.AA.normal) {
    contrastIssues.push({
      context: 'Dark mode: Primary button on background',
      ratio: darkPrimaryBg.ratio,
      message: darkPrimaryBg.recommendation || 'Insufficient contrast'
    });
  }

  const darkTextBg = validateContrast(colors.dark.textPrimary, colors.dark.bgPrimary);
  if (!darkTextBg.passes.AA.normal) {
    contrastIssues.push({
      context: 'Dark mode: Text on background',
      ratio: darkTextBg.ratio,
      message: 'Text color does not meet AA standards on primary background'
    });
  }

  // Generate warnings
  if (contrastIssues.length > 0) {
    warnings.push(`Found ${contrastIssues.length} accessibility issue(s). Review contrast ratios.`);
  }

  return { contrastIssues, warnings };
}

/**
 * Apply brand theme to DOM
 */
export function applyBrandTheme(brandTheme: BrandTheme, mode: 'light' | 'dark'): void {
  const root = document.documentElement;
  const colors = mode === 'light' ? brandTheme.colors.light : brandTheme.colors.dark;

  // Apply colors
  Object.entries(colors).forEach(([key, value]) => {
    const cssVar = `--color-${key.replace(/([A-Z])/g, '-$1').toLowerCase()}`;
    root.style.setProperty(cssVar, value);
  });

  // Apply branding
  if (brandTheme.branding.fontFamily) {
    root.style.setProperty('--font-family-base', brandTheme.branding.fontFamily);
  }
  if (brandTheme.branding.borderRadius) {
    root.style.setProperty('--radius-md', brandTheme.branding.borderRadius);
  }
}

Performance Optimization

Theme switching must be imperceptible to users. Optimize CSS delivery, cache themes aggressively, and minimize JavaScript execution.

Theme Cache System

// themeCacheSystem.ts - 96 lines
export interface CachedTheme {
  id: string;
  theme: any; // Your theme type
  timestamp: number;
  version: string;
}

export class ThemeCache {
  private static readonly CACHE_KEY = 'theme-cache-v2';
  private static readonly MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
  private static readonly MAX_ENTRIES = 5;

  /**
   * Store theme in cache
   */
  static set(id: string, theme: any, version: string = '1.0.0'): void {
    if (typeof window === 'undefined') return;

    try {
      const cache = this.getAll();
      const entry: CachedTheme = {
        id,
        theme,
        timestamp: Date.now(),
        version
      };

      // Remove old entry if exists
      const filtered = cache.filter(item => item.id !== id);

      // Add new entry
      filtered.unshift(entry);

      // Enforce max entries
      const trimmed = filtered.slice(0, this.MAX_ENTRIES);

      localStorage.setItem(this.CACHE_KEY, JSON.stringify(trimmed));
    } catch (error) {
      console.warn('Failed to cache theme:', error);
    }
  }

  /**
   * Retrieve theme from cache
   */
  static get(id: string, version: string = '1.0.0'): any | null {
    if (typeof window === 'undefined') return null;

    try {
      const cache = this.getAll();
      const entry = cache.find(item => item.id === id && item.version === version);

      if (!entry) return null;

      // Check expiration
      if (Date.now() - entry.timestamp > this.MAX_AGE) {
        this.remove(id);
        return null;
      }

      return entry.theme;
    } catch (error) {
      console.warn('Failed to retrieve cached theme:', error);
      return null;
    }
  }

  /**
   * Get all cached themes
   */
  private static getAll(): CachedTheme[] {
    if (typeof window === 'undefined') return [];

    try {
      const stored = localStorage.getItem(this.CACHE_KEY);
      return stored ? JSON.parse(stored) : [];
    } catch {
      return [];
    }
  }

  /**
   * Remove specific theme from cache
   */
  static remove(id: string): void {
    if (typeof window === 'undefined') return;

    try {
      const cache = this.getAll();
      const filtered = cache.filter(item => item.id !== id);
      localStorage.setItem(this.CACHE_KEY, JSON.stringify(filtered));
    } catch (error) {
      console.warn('Failed to remove cached theme:', error);
    }
  }

  /**
   * Clear all cached themes
   */
  static clear(): void {
    if (typeof window === 'undefined') return;

    try {
      localStorage.removeItem(this.CACHE_KEY);
    } catch (error) {
      console.warn('Failed to clear theme cache:', error);
    }
  }

  /**
   * Preload theme (used for anticipated theme switches)
   */
  static async preload(id: string, loader: () => Promise<any>, version: string = '1.0.0'): Promise<any> {
    const cached = this.get(id, version);
    if (cached) return cached;

    const theme = await loader();
    this.set(id, theme, version);
    return theme;
  }
}

Performance Best Practices:

  1. CSS-in-JS vs CSS Modules: Use CSS custom properties with CSS modules for best performance. CSS-in-JS adds 2-5KB runtime overhead and slower theme switches.

  2. Theme preloading: Preload alternate theme on idle using requestIdleCallback to enable instant switching.

  3. Critical CSS inlining: Inline theme tokens in <head> to prevent flash of unstyled content (FOUC).

  4. Reduce reflows: Apply all theme changes in a single requestAnimationFrame batch.

  5. Service worker caching: Cache theme CSS files with service workers for offline support.

Conclusion

Building a production-ready theme system for ChatGPT widgets requires CSS custom properties for dynamic styling, React Context for theme distribution, color science algorithms for palette generation, and contrast validation for accessibility compliance.

The theme systems demonstrated in this guide support dark mode, brand customization, SSR, theme persistence, and sub-50ms switching performance. By implementing these patterns, you ensure your widgets adapt seamlessly to user preferences while maintaining WCAG AA standards.

Ready to build ChatGPT apps with professional theming? MakeAIHQ provides a complete no-code platform for ChatGPT app development with built-in theme systems, brand customization, and accessibility validation. From zero to ChatGPT App Store in 48 hours—no coding required.

Related Resources

  • Complete Guide to Building ChatGPT Applications - Master ChatGPT app development fundamentals
  • React Widget Components for ChatGPT Apps - Build production-ready React widgets
  • Widget Accessibility and WCAG Compliance - Ensure accessible widget interfaces
  • CSS Design System for ChatGPT Widgets - Comprehensive design system architecture

About MakeAIHQ: We're the no-code ChatGPT app builder that helps businesses reach 800 million ChatGPT users. Create production-ready ChatGPT apps in 48 hours with our AI Conversational Editor and instant deployment to the ChatGPT App Store.