How styled() Really Works: The createStyled Pipeline
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 lookupslot: The sub-component slot (e.g.,'Root','StartIcon') — controlsshouldForwardPropbehavioroverridesResolver: A function mapping themestyleOverrideskeys to this componentskipVariantsResolver,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:
- Head —
attachThemeruns first, ensuring every subsequent expression sees the correctly scoped theme - Body — Your style functions/objects, transformed via
transformStylefor variant support and layer wrapping - 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:
- Functions — Called with
props, result recursed into - Arrays — Flatmapped through
processStylerecursively - Objects with
variants— Root styles extracted, then variant matching applied - Pre-processed objects (
isProcessed: true) — Style already serialized, returned directly - 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:
- Emotion calls all expressions in order with
{ theme, ownerState, ... } attachTheme(head) resolvestheme['$$material']from contextmemoTheme'd body expression returns pre-serialized base styles + variant matches forcontained+primarystyleThemeOverrides(tail) checkstheme.components.MuiButton.styleOverrides— if present,processStyleresolves overrides for therootslotstyleThemeVariants(tail) checkstheme.components.MuiButton.variants— any matching{props, style}pairs are appendedstyleFunctionSx(tail) processes{ mt: 2 }into{ marginTop: '16px' }, wrapped in@layer sxif 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
sxprop isn't overriding a theme override, thesxprocessing might be skipped (skipSx: true). If a variant isn't matching, check that the prop values onownerStateexactly match the variant'sprops(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.