Read OSS

How styled() Really Works: The createStyled Pipeline

Advanced

Prerequisites

  • Article 1: Architecture and Package Layers
  • Emotion styled() API
  • CSS specificity and cascade concepts
  • JavaScript closures and factory functions

How styled() Really Works: The createStyled Pipeline

In Part 1, we mapped the four-layer dependency pyramid and saw that Material UI's styled export is created by calling createStyled() with a config object. Now we open that factory function and trace every expression, every optimization, and every design decision inside it. This is the code that runs for every styled component in the library — understanding it means understanding how all MUI styling works.

The core implementation lives in a single file: packages/mui-system/src/createStyled/createStyled.js. At 351 lines, it's dense but comprehensible once you see the structure.

createStyled: The Factory Function

createStyled is a factory that returns a styled function. The factory accepts configuration that gets closed over and shared by every component created with the returned styled:

export default function createStyled(input = {}) {
  const {
    themeId,
    defaultTheme = systemDefaultTheme,
    rootShouldForwardProp = shouldForwardProp,
    slotShouldForwardProp = shouldForwardProp,
  } = input;

  const styled = (tag, inputOptions = {}) => {
    // ...returns muiStyledResolver
  };

  return styled;
}

As we saw in Part 1, Material UI calls this factory once with { themeId: '$$material', defaultTheme, rootShouldForwardProp } and exports the result. That single call creates the styled function used by every component in the library.

flowchart TD
    Factory["createStyled({ themeId, defaultTheme, rootShouldForwardProp })"]
    Factory --> StyledFn["styled() — closed over config"]
    StyledFn --> |"styled(ButtonBase, { name: 'MuiButton', slot: 'Root' })"|Resolver["muiStyledResolver"]
    Resolver --> |"muiStyledResolver(styles...)"|Component["React Component"]

The returned styled function accepts two arguments: a tag (HTML element string or React component) and an options object with MUI-specific metadata:

  • name: The component name (e.g., 'MuiButton') — used for theme overrides lookup
  • slot: The sub-component slot (e.g., 'Root', 'StartIcon') — controls shouldForwardProp behavior
  • overridesResolver: A function mapping theme styleOverrides keys to this component
  • skipVariantsResolver, skipSx: Opt-outs for specific pipeline stages

The Three-Zone Expression Pipeline

The heart of createStyled is the muiStyledResolver function, defined at line 216. When you write styled('div')({ color: 'red' }), the style objects you pass are not the only expressions fed to Emotion. MUI wraps them in a three-zone pipeline:

const muiStyledResolver = (...expressionsInput) => {
  const expressionsHead = [];
  const expressionsBody = expressionsInput.map(transformStyle);
  const expressionsTail = [];

  // HEAD: Theme attachment — must run first
  expressionsHead.push(styleAttachTheme);

  // TAIL: Theme overrides
  if (componentName && overridesResolver) {
    expressionsTail.push(styleThemeOverrides);
  }

  // TAIL: Theme variants
  if (componentName && !skipVariantsResolver) {
    expressionsTail.push(styleThemeVariants);
  }

  // TAIL: sx prop — always last
  if (!skipSx) {
    expressionsTail.push(styleFunctionSx);
  }

  const expressions = [...expressionsHead, ...expressionsBody, ...expressionsTail];
  const Component = defaultStyledResolver(...expressions);
  return Component;
};
sequenceDiagram
    participant Dev as Developer
    participant MSR as muiStyledResolver
    participant Emotion as Emotion styled

    Dev->>MSR: styled('div')(style1, style2)
    MSR->>MSR: expressionsHead = [attachTheme]
    MSR->>MSR: expressionsBody = [transform(style1), transform(style2)]
    MSR->>MSR: expressionsTail = [overrides, variants, sx]
    MSR->>Emotion: styled('div')(attachTheme, style1', style2', overrides, variants, sx)
    Emotion-->>Dev: React Component

The ordering is deliberate and dictates specificity:

  1. HeadattachTheme runs first, ensuring every subsequent expression sees the correctly scoped theme
  2. Body — Your style functions/objects, transformed via transformStyle for variant support and layer wrapping
  3. Tail — Theme overrides, theme variants, then sx — each progressively higher specificity

This means sx always wins over theme overrides, theme overrides always win over component defaults, and component defaults always win over base styles. The CSS cascade is being managed programmatically.

processStyle and Variant Matching

The processStyle function at line 48 is the style resolution engine. It handles five input types:

  1. Functions — Called with props, result recursed into
  2. Arrays — Flatmapped through processStyle recursively
  3. Objects with variants — Root styles extracted, then variant matching applied
  4. Pre-processed objects (isProcessed: true) — Style already serialized, returned directly
  5. Plain objects/strings — Returned as-is (possibly wrapped in a CSS layer)

The variant matching happens in processStyleVariants at line 85:

function processStyleVariants(props, variants, results = [], layerName = undefined) {
  let mergedState;

  variantLoop: for (let i = 0; i < variants.length; i += 1) {
    const variant = variants[i];

    if (typeof variant.props === 'function') {
      mergedState ??= { ...props, ...props.ownerState, ownerState: props.ownerState };
      if (!variant.props(mergedState)) {
        continue;
      }
    } else {
      for (const key in variant.props) {
        if (props[key] !== variant.props[key] &&
            props.ownerState?.[key] !== variant.props[key]) {
          continue variantLoop;
        }
      }
    }

    // Variant matched — add its style
    results.push(/* resolved style */);
  }

  return results;
}

Two performance details stand out. First, the variantLoop: label with continue variantLoop — this is a labeled loop, a rarely used JavaScript feature that lets the inner for...in loop skip straight to the next variant when any prop doesn't match. This avoids creating an intermediate Object.entries() array or calling Object.keys().every(). For a function that runs on every render of every styled component, that matters.

Second, mergedState uses nullish coalescing assignment (??=) for lazy initialization. It's only allocated when a variant uses function props, which most don't.

Tip: When defining variants in your theme, prefer the object-props form ({ props: { variant: 'contained', color: 'primary' } }) over function props for best performance. The object form uses a fast property-by-property check; function props require allocating a merged state object.

Performance: memoTheme and preprocessStyles

Every styled component in Material UI wraps its style function in memoTheme. Look at Button's root styles:

const ButtonRoot = styled(ButtonBase, { name: 'MuiButton', slot: 'Root' })(
  memoTheme(({ theme }) => {
    return { /* hundreds of lines of styles */ };
  })
);

The implementation at packages/mui-system/src/memoTheme.ts is a closure-based referential identity check:

export default function unstable_memoTheme<T>(styleFn: ThemeStyleFunction<T>) {
  let lastValue: CSSInterpolation;
  let lastTheme: T;

  return function styleMemoized(props: { theme: T }) {
    let value = lastValue;
    if (value === undefined || props.theme !== lastTheme) {
      arg.theme = props.theme;
      value = preprocessStyles(styleFn(arg));
      lastValue = value;
      lastTheme = props.theme;
    }
    return value;
  };
}

The theme object is typically stable across renders (same identity), so this memoization means the style function body — which may contain dozens of variant definitions and theme lookups — executes only once per theme change, not once per render.

flowchart TD
    Render1["Render 1: theme = T1"] --> Check1{"lastTheme === T1?"}
    Check1 -->|"No (first render)"| Compute["Call styleFn → preprocessStyles → cache"]
    Check1 -->|"Yes"| Return["Return cached value"]
    Compute --> Return
    Render2["Render 2: theme = T1"] --> Check2{"lastTheme === T1?"}
    Check2 -->|"Yes"| Return2["Return cached value ⚡"]

The result is then passed through preprocessStyles at packages/mui-system/src/preprocessStyles.ts:

export default function preprocessStyles(input: any) {
  const { variants, ...style } = input;
  const result = {
    variants,
    style: internal_serializeStyles(style) as any,
    isProcessed: true,
  };

  if (variants) {
    variants.forEach((variant: any) => {
      if (typeof variant.style !== 'function') {
        variant.style = internal_serializeStyles(variant.style);
      }
    });
  }
  return result;
}

This is where Emotion's serializeStyles gets called — converting style objects into pre-hashed CSS strings with deterministic class names. By doing this at memoization time rather than render time, the cost of CSS serialization is paid only when the theme changes.

CSS Layers for Specificity Control

The transformStyle function at line 184 checks props.theme.modularCssLayers and, when enabled, wraps styles in @layer directives. The layer name is determined by context:

const layerName =
  (componentName && componentName.startsWith('Mui')) || !!componentSlot
    ? 'components'
    : 'custom';

Three layers are used throughout the pipeline:

Layer Where Used Purpose
components Body expressions (MUI component styles) Base component styling
theme Tail expressions (styleOverrides, variants) Theme customization
sx styleFunctionSx (tail, last position) Inline escape hatch
flowchart BT
    L1["@layer components — Base component styles"]
    L2["@layer theme — styleOverrides + variants"]
    L3["@layer sx — sx prop styles"]

    L1 --> L2 --> L3

    style L3 fill:#c62828,color:#fff
    style L2 fill:#e65100,color:#fff
    style L1 fill:#1565c0,color:#fff

The shallowLayer helper wraps already-serialized CSS:

function shallowLayer(serialized, layerName) {
  if (layerName && serialized?.styles && !serialized.styles.startsWith('@layer')) {
    serialized.styles = `@layer ${layerName}{${String(serialized.styles)}}`;
  }
  return serialized;
}

This is a significant architectural improvement over relying on injection order for specificity. With CSS layers, the cascade is deterministic regardless of which styles load first — making SSR hydration and code-splitting much more predictable.

Putting It All Together

Let's trace what happens when a <Button variant="contained" color="primary" sx={{ mt: 2 }}> renders:

  1. Emotion calls all expressions in order with { theme, ownerState, ... }
  2. attachTheme (head) resolves theme['$$material'] from context
  3. memoTheme'd body expression returns pre-serialized base styles + variant matches for contained + primary
  4. styleThemeOverrides (tail) checks theme.components.MuiButton.styleOverrides — if present, processStyle resolves overrides for the root slot
  5. styleThemeVariants (tail) checks theme.components.MuiButton.variants — any matching {props, style} pairs are appended
  6. styleFunctionSx (tail) processes { mt: 2 } into { marginTop: '16px' }, wrapped in @layer sx if layers are enabled

The specificity ordering is guaranteed: base → overrides → variants → sx.

Tip: When debugging why a style isn't applying, check which zone it's in. If your sx prop isn't overriding a theme override, the sx processing might be skipped (skipSx: true). If a variant isn't matching, check that the prop values on ownerState exactly match the variant's props (strict equality, no type coercion).

What's Next

We now understand the styling pipeline that powers every MUI component. In Part 3, we'll see how a specific component — Button — is actually built: its five-file directory convention, the props resolution chain from JSX to final render, the ownerState pattern, and the CSS class generation system. We'll move from the abstract pipeline to concrete component anatomy.