The Five Layers of Component Customization
Prerequisites
- ›Articles 1-5
- ›CSS specificity and cascade rules
- ›CSS @layer concept
The Five Layers of Component Customization
This is where everything converges. Across five articles, we've traced the architecture, the styled pipeline, component anatomy, the theme system, and the build tooling. Now we map the complete customization surface area — five distinct mechanisms ordered by specificity — and see how they compose into a deterministic, non-conflicting cascade.
Material UI is sometimes criticized for being "hard to customize." The reality is the opposite: it provides too many customization options, and the challenge is understanding when to use which. This article resolves that confusion by showing exactly how each layer interacts with the styled pipeline from Part 2.
Layer 1: Default Props via Theme
The lightest-touch customization. You change the default prop values for a component without writing any CSS:
const theme = createTheme({
components: {
MuiButton: {
defaultProps: {
variant: 'contained',
disableElevation: true,
},
},
},
});
As we traced in Part 3, these defaults flow through useDefaultProps via the DefaultPropsProvider context. The resolution happens in getThemeProps:
function getThemeProps(params) {
const { theme, name, props } = params;
const config = theme.components[name];
if (config.defaultProps) {
return resolveProps(config.defaultProps, props);
}
return props;
}
The resolveProps utility ensures explicit props always override defaults — only undefined prop values fall through to the default.
flowchart LR
ThemeDefaults["theme.components.MuiButton.defaultProps<br/>{variant: 'contained'}"]
UserProps["<Button color='primary' />"]
Resolved["Final: {variant: 'contained', color: 'primary'}"]
ThemeDefaults --> Resolved
UserProps --> Resolved
Tip: Default props are the right choice when you want to change component behavior across your app — default variant, default size, default color. If you want to change the appearance of a particular variant/state combination, you need Layer 2 or 3.
Layer 2: Theme Variants — Conditional Style Objects
Theme variants let you add entirely new prop-style combinations or override existing ones. They're defined as arrays of {props, style} objects:
const theme = createTheme({
components: {
MuiButton: {
variants: [
{
props: { variant: 'dashed' },
style: { border: '2px dashed currentColor' },
},
],
},
},
});
In the styled pipeline from Part 2, these variants are injected as the second tail expression at line 249:
if (componentName && !skipVariantsResolver) {
expressionsTail.push(function styleThemeVariants(props) {
const themeVariants = theme?.components?.[componentName]?.variants;
if (!themeVariants) {
return null;
}
return processStyleVariants(
props, themeVariants, [],
props.theme.modularCssLayers ? 'theme' : undefined,
);
});
}
Notice the layer name 'theme' — when CSS layers are enabled, theme variants land in the @layer theme block, giving them higher specificity than base component styles in @layer components.
Layer 3: Theme styleOverrides — Per-Slot CSS
Style overrides let you target specific sub-component slots with arbitrary CSS:
const theme = createTheme({
components: {
MuiButton: {
styleOverrides: {
root: { textTransform: 'none' },
startIcon: { marginRight: 4 },
},
},
},
});
The override injection at line 225 processes each slot through processStyle, then passes the resolved overrides to the component's overridesResolver:
expressionsTail.push(function styleThemeOverrides(props) {
const styleOverrides = theme.components?.[componentName]?.styleOverrides;
if (!styleOverrides) return null;
const resolvedStyleOverrides = {};
for (const slotKey in styleOverrides) {
resolvedStyleOverrides[slotKey] = processStyle(
props, styleOverrides[slotKey],
props.theme.modularCssLayers ? 'theme' : undefined,
);
}
return overridesResolver(props, resolvedStyleOverrides);
});
The overridesResolver for Button (from Part 3) then picks the applicable override keys — styles.root, styles[ownerState.variant], etc. — and returns them as an array.
Style overrides are more powerful than variants because they can target individual sub-component slots. A variant can only style the component that matches the variant's props; an override can independently style the root, the startIcon, the endIcon, and the loading indicator.
flowchart TD
Overrides["theme.components.MuiButton.styleOverrides"]
Root["root: { textTransform: 'none' }"]
Start["startIcon: { marginRight: 4 }"]
End["endIcon: { color: 'red' }"]
Overrides --> Root
Overrides --> Start
Overrides --> End
Root -->|"overridesResolver maps to"| ButtonRoot["ButtonRoot sub-component"]
Start -->|"overridesResolver maps to"| ButtonStartIcon["ButtonStartIcon sub-component"]
End -->|"overridesResolver maps to"| ButtonEndIcon["ButtonEndIcon sub-component"]
Layer 4: The styled() API for Derivative Components
When theme-level customization isn't enough, you create a new component by wrapping an MUI component with styled():
import { styled } from '@mui/material/styles';
import Button from '@mui/material/Button';
const GradientButton = styled(Button)({
background: 'linear-gradient(45deg, #FE6B8B, #FF8E53)',
color: 'white',
});
When you styled() an already-styled component, the system from Part 2 handles deduplication. The first thing muiStyledResolver does is filter out the sx handler from the parent:
mutateStyles(tag, (styles) =>
styles.filter((style) => style !== styleFunctionSx)
);
This prevents the sx expression from being applied twice — once from the parent's expression pipeline and again from the wrapper's pipeline. The wrapper gets its own fresh set of head/body/tail expressions.
Your styles land in the body zone of the new component's pipeline, which means they're processed after theme attachment but before theme overrides, theme variants, and sx. The existing three layers of theme customization still apply on top.
Layer 5: The sx Prop — Highest Specificity Escape Hatch
The sx prop is the ultimate override, processed last in the expression pipeline. It's implemented by styleFunctionSx:
function styleFunctionSx(props) {
if (!props.sx) return null;
const { sx, theme = EMPTY_THEME, nested } = props;
const config = theme.unstable_sxConfig ?? defaultSxConfig;
function process(sxInput) {
let sxObject = typeof sxInput === 'function' ? sxInput(theme) : sxInput;
// ...resolve breakpoints, theme-aware shorthands
}
return Array.isArray(sx) ? sx.map(process) : process(sx);
}
The sx prop supports three input forms:
- Object:
sx={{ mt: 2, color: 'primary.main' }}— theme-aware shorthand properties - Function:
sx={(theme) => ({ mt: theme.spacing(2) })}— full theme access - Array:
sx={[{ mt: 2 }, isActive && { color: 'red' }]}— conditional composition
When CSS layers are enabled, sx styles are wrapped in @layer sx at line 71:
if (!nested && theme.modularCssLayers) {
return { '@layer sx': sortContainerQueries(theme, removeUnusedBreakpoints(breakpoints, css)) };
}
This ensures sx always has the highest CSS specificity among MUI's customization layers.
CSS Custom Properties for Variant Efficiency
Button's styles reveal a clever optimization. Instead of defining separate styles for every variant × color combination (text-primary, text-secondary, contained-primary, contained-secondary, etc.), Button uses CSS custom properties as an indirection layer.
From Button.js lines 118-194:
// Variant styles reference custom properties
{ props: { variant: 'contained' },
style: {
color: `var(--variant-containedColor)`,
backgroundColor: `var(--variant-containedBg)`,
},
},
// Color styles set those custom properties
...Object.entries(theme.palette)
.filter(createSimplePaletteValueFilter())
.map(([color]) => ({
props: { color },
style: {
'--variant-textColor': (theme.vars || theme).palette[color].main,
'--variant-containedColor': (theme.vars || theme).palette[color].contrastText,
'--variant-containedBg': (theme.vars || theme).palette[color].main,
},
})),
flowchart TD
Variant["variant: 'contained'<br/>color: var(--variant-containedColor)<br/>bg: var(--variant-containedBg)"]
ColorPrimary["color: 'primary'<br/>--variant-containedColor: #fff<br/>--variant-containedBg: #1976d2"]
ColorSecondary["color: 'secondary'<br/>--variant-containedColor: #fff<br/>--variant-containedBg: #9c27b0"]
ColorPrimary -->|"CSS custom props"| Variant
ColorSecondary -->|"CSS custom props"| Variant
Without this pattern, you'd need 3 variants × 7 colors = 21 separate style blocks. With CSS custom properties, you need 3 variant blocks + 7 color blocks = 10 blocks. This scales linearly instead of multiplicatively. As you add custom colors via the palette, only the color blocks grow — the variant blocks stay unchanged.
Tip: When creating custom components, adopt the CSS custom property indirection pattern for any prop that combines multiplicatively with another. Define variant layouts using
var(--your-prop), then set those properties per-color/per-size. This produces dramatically less CSS output.
CSS @layer Integration for Specificity Management
With CSS layers enabled via theme.modularCssLayers, each customization layer maps to a CSS cascade layer:
| Customization Layer | CSS Layer | Specificity Order |
|---|---|---|
| Base component styles (body expressions) | @layer components |
Lowest |
Theme styleOverrides + variants (tail expressions) |
@layer theme |
Middle |
sx prop |
@layer sx |
Highest |
The layer assignment happens at multiple points in the pipeline. In transformStyle at line 195, body expressions check props.theme.modularCssLayers:
return function styleFunctionProcessor(props) {
return processStyle(
props, style,
props.theme.modularCssLayers ? layerName : undefined
);
};
The layer name for a component is determined by its name option at line 149:
const layerName =
(componentName && componentName.startsWith('Mui')) || !!componentSlot
? 'components'
: 'custom';
MUI's own components get @layer components. User-created styled components (without a Mui* name) get @layer custom.
flowchart BT
Components["@layer components<br/>MUI base styles"]
Custom["@layer custom<br/>User styled() components"]
ThemeLayer["@layer theme<br/>styleOverrides + variants"]
SxLayer["@layer sx<br/>sx prop styles"]
Components --> Custom
Custom --> ThemeLayer
ThemeLayer --> SxLayer
style SxLayer fill:#b71c1c,color:#fff
style ThemeLayer fill:#e65100,color:#fff
style Custom fill:#1565c0,color:#fff
style Components fill:#283593,color:#fff
This solves a long-standing pain point with CSS-in-JS: style injection order. Without layers, whichever styled component is rendered first gets its styles injected first, and later styles could unintentionally override them due to equal specificity. With @layer, the cascade order is defined by the layer declaration, not injection order. SSR hydration mismatches, code-splitting reorders, and dynamic import timing no longer affect specificity.
Putting All Five Layers Together
Let's trace a fully customized Button through all five layers:
// Layer 1: Default props
const theme = createTheme({
components: {
MuiButton: {
defaultProps: { variant: 'contained' }, // Layer 1
variants: [{ props: { size: 'xl' }, style: {} }], // Layer 2
styleOverrides: { root: { borderRadius: 8 } }, // Layer 3
},
},
});
// Layer 4: Derivative component
const MyButton = styled(Button)({ fontWeight: 700 });
// Layer 5: Instance override
<MyButton sx={{ mt: 2 }}>Click me</MyButton>
The final CSS for this Button contains styles from all five layers, each wrapping the next in specificity:
- The
variant: 'contained'default prop selects the contained variant styles from the base component definition - Any matching theme variants from
theme.components.MuiButton.variantsare appended - The
borderRadius: 8fromstyleOverrides.rootis applied - The
fontWeight: 700from thestyled()wrapper is applied - The
marginTop: 16pxfromsx={{ mt: 2 }}is applied last, at highest specificity
This five-layer model gives you customization at every level of abstraction — from global defaults to single-instance overrides — with a predictable, deterministic cascade.
Series Conclusion
Over these six articles, we've traced Material UI from its pnpm workspace roots through the styled-engine abstraction, the createStyled expression pipeline, component anatomy with Button as our exemplar, the dual-path theme system with CSS variables, the build infrastructure, and finally the complete customization surface.
The overarching design philosophy is clear: every layer is an adapter, and every adapter is swappable. The styled engine can be swapped (Emotion ↔ styled-components). The theme mode can be swapped (classic ↔ CSS variables). The component theme can be scoped (Material ↔ Joy via THEME_ID). The specificity model can be swapped (injection order ↔ CSS layers).
This layered, adapter-based architecture is what lets Material UI serve millions of applications with wildly different requirements — from a simple dashboard with default settings to a complex design system with custom themes, variant extensions, and per-instance overrides — all from the same codebase.