Read OSS

The Five Layers of Component Customization

Advanced

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:

  1. The variant: 'contained' default prop selects the contained variant styles from the base component definition
  2. Any matching theme variants from theme.components.MuiButton.variants are appended
  3. The borderRadius: 8 from styleOverrides.root is applied
  4. The fontWeight: 700 from the styled() wrapper is applied
  5. The marginTop: 16px from sx={{ 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.