Read OSS

The Dual-Path Theme System: Classic Objects and CSS Variables

Advanced

Prerequisites

  • Articles 1-3
  • CSS custom properties (var() syntax, cascade)
  • React context and state management patterns

The Dual-Path Theme System: Classic Objects and CSS Variables

Throughout Parts 1–3, you've seen (theme.vars || theme).palette.primary.main scattered through style definitions. This expression is the surface trace of a much deeper architectural decision: Material UI supports two fundamentally different theming approaches through a single component API. Classic mode stores theme values as JavaScript objects resolved at render time. CSS variables mode flattens the theme into CSS custom properties injected once into the document. Understanding how these paths diverge and reconverge is essential for advanced customization.

createTheme: The Routing Function

The entry point is createTheme, and its core logic is a routing decision:

export default function createTheme(options = {}, ...args) {
  const { palette, cssVariables = false, colorSchemes, ... } = options;

  if (cssVariables === false) {
    // ...classic path
    return createThemeNoVars(options, ...args);
  }

  // CSS variables path
  return createThemeWithVars({ ...other, colorSchemes, ... }, ...args);
}
flowchart TD
    CT["createTheme(options)"]
    CT --> Check{"cssVariables?"}
    Check -->|"false (default)"| NoVars["createThemeNoVars()"]
    Check -->|"true or config"| WithVars["createThemeWithVars()"]
    NoVars --> Theme1["Theme object<br/>palette.primary.main = '#1976d2'"]
    WithVars --> Theme2["Theme object<br/>vars.palette.primary.main = 'var(--mui-palette-primary-main)'"]

The same routing happens in ThemeProvider at packages/mui-material/src/styles/ThemeProvider.tsx:

export default function ThemeProvider({ theme, ...props }) {
  const noVarsTheme = React.useMemo(() => {
    const muiTheme = (THEME_ID in theme ? theme[THEME_ID] : theme);
    if (!('colorSchemes' in muiTheme)) {
      return { ...theme, vars: null };
    }
    return null;
  }, [theme]);

  if (noVarsTheme) {
    return <ThemeProviderNoVars theme={noVarsTheme} {...props} />;
  }
  return <CssVarsProvider theme={theme} {...props} />;
}

The check is elegant: if the theme has colorSchemes, it's a CSS variables theme that needs the full CssVarsProvider with color scheme management. Otherwise, it's a classic theme that just needs context injection.

Classic Theme Construction

The classic path at createThemeNoVars assembles the theme through composition:

function createThemeNoVars(options = {}, ...args) {
  const {
    breakpoints: breakpointsInput,
    palette: paletteInput = {},
    typography: typographyInput = {},
    shape: shapeInput,
    ...other
  } = options;

  const palette = createPalette({ ...paletteInput });
  const systemTheme = systemCreateTheme(options);

  let muiTheme = deepmerge(systemTheme, {
    mixins: createMixins(systemTheme.breakpoints, mixinsInput),
    palette,
    shadows: shadows.slice(),
    typography: createTypography(palette, typographyInput),
    transitions: createTransitions(transitionsInput),
    zIndex: { ...zIndex },
  });

  // Deep merge any additional arguments
  muiTheme = args.reduce((acc, argument) => deepmerge(acc, argument), muiTheme);

  attachColorManipulators(muiTheme);
  return muiTheme;
}
flowchart LR
    Input["options"] --> Palette["createPalette()"]
    Input --> System["systemCreateTheme()"]
    Input --> Typography["createTypography()"]
    Input --> Transitions["createTransitions()"]

    System --> Merge["deepmerge()"]
    Palette --> Merge
    Typography --> Merge
    Transitions --> Merge
    Merge --> Manipulators["attachColorManipulators()"]
    Manipulators --> Theme["Complete Theme Object"]

Each sub-system factory takes partial input and produces a complete section. createPalette generates light/dark variants, contrast text colors, and action palette entries. createTypography maps variant names to CSS font declarations. The result is a flat JavaScript object where theme.palette.primary.main is a string like '#1976d2'.

The CSS Variables Path

When cssVariables is truthy, createThemeWithVars at line 127 takes over. It builds the same theme structure but then flattens it into CSS custom property declarations using prepareCssVars.

The key function is prepareCssVars from @mui/system:

function prepareCssVars(theme, parserConfig = {}) {
  const { colorSchemes = {}, defaultColorScheme = 'light', ...otherTheme } = theme;

  const { vars: rootVars, css: rootCss, varsWithDefaults: rootVarsWithDefaults } =
    cssVarsParser(otherTheme, parserConfig);

  let themeVars = rootVarsWithDefaults;

  const colorSchemesMap = {};
  Object.entries(otherColorSchemes).forEach(([key, scheme]) => {
    const { vars, css, varsWithDefaults } = cssVarsParser(scheme, parserConfig);
    themeVars = deepmerge(themeVars, varsWithDefaults);
    colorSchemesMap[key] = { css, vars };
  });

  // ...
}

The cssVarsParser walks a nested object and produces three outputs:

  • css: A flat map of CSS variable declarations ({ '--mui-palette-primary-main': '#1976d2' })
  • vars: A flat map of CSS variable references ({ palette: { primary: { main: 'var(--mui-palette-primary-main)' } } })
  • varsWithDefaults: Same structure as vars but with fallback values embedded

The result is that theme.vars.palette.primary.main contains the string 'var(--mui-palette-primary-main)', while theme.palette.primary.main still contains '#1976d2'. Both are available on the same theme object.

The (theme.vars || theme) Compatibility Pattern

This brings us to the most pervasive pattern in the codebase. Every style that references a theme value uses:

color: (theme.vars || theme).palette.primary.main,

In classic mode, theme.vars is undefined (or null, explicitly set by ThemeProvider), so the expression evaluates to theme.palette.primary.main'#1976d2'.

In CSS variables mode, theme.vars exists, so it evaluates to theme.vars.palette.primary.main'var(--mui-palette-primary-main)'.

flowchart TD
    Style["borderRadius: (theme.vars || theme).shape.borderRadius"]

    Style --> Classic{"theme.vars exists?"}
    Classic -->|"No (classic)"| Raw["theme.shape.borderRadius → '4px'"]
    Classic -->|"Yes (CSS vars)"| Var["theme.vars.shape.borderRadius → 'var(--mui-shape-borderRadius)'"]

    Raw --> CSS1["border-radius: 4px"]
    Var --> CSS2["border-radius: var(--mui-shape-borderRadius)"]

This pattern appears hundreds of times in the Button component alone. Look at line 105:

borderRadius: (theme.vars || theme).shape.borderRadius,

And the variant color assignments at line 171-178:

'--variant-textColor': (theme.vars || theme).palette[color].main,
'--variant-containedColor': (theme.vars || theme).palette[color].contrastText,
'--variant-containedBg': (theme.vars || theme).palette[color].main,

Tip: If you're writing custom styled components with MUI's styled(), always use (theme.vars || theme) when referencing theme tokens. This ensures your component works correctly regardless of whether the consumer enables cssVariables.

Color Scheme Management and Color Manipulation

When CSS variables are enabled, the CssVarsProvider from createCssVarsProvider manages color scheme switching. It creates a ColorSchemeContext, uses the useCurrentColorScheme hook to track the active scheme, and persists the choice to localStorage:

export default function createCssVarsProvider(options) {
  const {
    themeId,
    theme: defaultTheme = {},
    modeStorageKey: defaultModeStorageKey = DEFAULT_MODE_STORAGE_KEY,
    colorSchemeStorageKey: defaultColorSchemeStorageKey = DEFAULT_COLOR_SCHEME_STORAGE_KEY,
    defaultColorScheme,
    resolveTheme,
  } = options;

  const ColorSchemeContext = React.createContext(undefined);
  const useColorScheme = () => React.useContext(ColorSchemeContext) || defaultContext;

  function CssVarsProvider(props) {
    // ...manages mode, colorScheme, injects CSS variables as GlobalStyles
  }

  return { CssVarsProvider, useColorScheme, ... };
}

The color manipulation system is equally thoughtful. The attachColorManipulators function in createThemeNoVars.js adds alpha(), lighten(), and darken() methods directly to the theme object. These methods are context-aware:

function attachColorManipulators(theme) {
  Object.assign(theme, {
    alpha(color, coefficient) {
      const obj = this || theme;
      if (obj.colorSpace) {
        return `oklch(from ${color} l c h / ${coefficient})`;
      }
      if (obj.vars) {
        return `rgba(${color.replace(/var\(--([^,\s)]+).../, 'var(--$1Channel)')} / ${coefficient})`;
      }
      return systemAlpha(color, parseAddition(coefficient));
    },
    lighten(color, coefficient) {
      const obj = this || theme;
      if (obj.colorSpace) {
        return `color-mix(in ${obj.colorSpace}, ${color}, #fff ${...})`;
      }
      return systemLighten(color, coefficient);
    },
  });
}

Three execution paths, one API:

Mode theme.alpha('#1976d2', 0.5) produces
Classic 'rgba(25, 118, 210, 0.5)' (computed JS value)
CSS Variables 'rgba(var(--mui-palette-primary-mainChannel) / 0.5)' (CSS expression)
Native Color (oklch) 'oklch(from #1976d2 l c h / 0.5)' (CSS function)

The nativeColor option enables oklch as the color space, producing perceptually uniform color manipulations directly in CSS without JavaScript computation.

sequenceDiagram
    participant Component as Styled Component
    participant Theme as theme.alpha()
    participant Output as CSS Output

    Component->>Theme: theme.alpha(palette.primary.main, 0.5)
    alt Classic mode
        Theme->>Output: rgba(25, 118, 210, 0.5)
    else CSS Variables mode
        Theme->>Output: rgba(var(--mui-palette-primary-mainChannel) / 0.5)
    else oklch mode
        Theme->>Output: oklch(from var(--mui-palette-primary-main) l c h / 0.5)
    end

What's Next

We've now traced both paths through the theme system — classic objects and CSS variables — and seen how every component stays compatible with both via (theme.vars || theme). In Part 5, we'll step back from runtime code and examine the build infrastructure: the error minification system, TypeScript-to-PropTypes generation, Nx caching, and the documentation site's development workflow.