Read OSS

Anatomy of a Material UI Component: From Props to Pixels

Intermediate

Prerequisites

  • Article 1: Architecture and Package Layers
  • Article 2: The createStyled Pipeline
  • React.forwardRef and refs
  • TypeScript interface basics

Anatomy of a Material UI Component: From Props to Pixels

After two articles exploring the macro architecture and styling pipeline, it's time to zoom into a single component and see how all those systems compose at the implementation level. Button is the ideal specimen — complex enough to showcase every pattern, familiar enough to not need explanation of what it does.

Every Material UI component follows a strict structural contract. Once you understand Button, you can read any component in the library. Let's dissect it.

The Five-File Component Convention

Every component directory follows a predictable structure. Here's Button:

File Purpose
Button.js Implementation — styled sub-components + render function
Button.d.ts TypeScript declarations — public API types
buttonClasses.ts CSS class tokens — generated utility class names
index.js Reexports — public barrel for this component
index.d.ts TypeScript reexports — public type barrel

The index.js is minimal:

export { default } from './Button';
export { default as buttonClasses } from './buttonClasses';
export * from './buttonClasses';

Every component file starts with 'use client'; — this React Server Components directive tells Next.js (and other RSC-capable frameworks) that this module requires client-side execution. Since Material UI components use hooks and browser APIs, they must run on the client.

flowchart TD
    barrel["@mui/material/Button/index.js"]
    impl["Button.js — implementation"]
    types["Button.d.ts — TypeScript API"]
    classes["buttonClasses.ts — CSS tokens"]

    barrel --> impl
    barrel --> classes
    types -.->|"types for"| impl

Props Resolution Chain

Button's render function reveals a three-step props resolution chain. Look at the opening of Button.js:

const Button = React.forwardRef(function Button(inProps, ref) {
  // Step 1: Read context props from ButtonGroup
  const contextProps = React.useContext(ButtonGroupContext);
  // Step 2: Merge context + direct props (direct wins)
  const resolvedProps = resolveProps(contextProps, inProps);
  // Step 3: Apply theme defaults
  const props = useDefaultProps({ props: resolvedProps, name: 'MuiButton' });

  const {
    color = 'primary',
    variant = 'text',
    size = 'medium',
    // ...
  } = props;
flowchart LR
    JSX["<Button color='primary' />"] -->|"inProps"| Resolve["resolveProps(contextProps, inProps)"]
    Context["ButtonGroupContext"] -->|"contextProps"| Resolve
    Resolve -->|"resolvedProps"| Defaults["useDefaultProps({ name: 'MuiButton' })"]
    Theme["theme.components.MuiButton.defaultProps"] -->|"via DefaultPropsProvider"| Defaults
    Defaults -->|"final props"| Destructure["Destructure with fallback defaults"]

The resolveProps utility at packages/mui-utils/src/resolveProps/resolveProps.ts isn't a simple spread. It does smart merging:

  • slots and components are shallow-merged (not overridden)
  • slotProps and componentsProps are recursively merged per-slot
  • Other props: explicit props win over defaults (undefined means "use default")

The useDefaultProps hook at packages/mui-system/src/DefaultPropsProvider/DefaultPropsProvider.tsx uses React context — not the theme directly. A DefaultPropsProvider wraps the tree with a context value containing default props for all components. This is faster than reading from the full theme object on every render.

Tip: The priority order is: explicit JSX props > React context props (e.g., from ButtonGroup) > theme default props > inline destructuring defaults. If your prop isn't taking effect, check whether a parent context is overriding it.

The ownerState Pattern

After props are resolved, Button constructs an ownerState object at line 529:

const ownerState = {
  ...props,
  color,
  component,
  disabled,
  disableElevation,
  disableFocusRipple,
  fullWidth,
  loading,
  loadingIndicator,
  loadingPosition,
  size,
  type,
  variant,
};

This object is the single source of truth for the component's resolved state. It gets passed to every styled sub-component as a prop:

<ButtonRoot ownerState={ownerState} ... />
<ButtonStartIcon ownerState={ownerState} ... />

Inside styled sub-components, ownerState is available in style functions via props.ownerState. The variant matching system (from Part 2) checks both props[key] and props.ownerState[key], which is how variant styles like { props: { variant: 'contained' }, style: {...} } match against the ownerState.

But ownerState must never reach the DOM. The shouldForwardProp function at line 20 filters it out:

export function shouldForwardProp(prop) {
  return prop !== 'ownerState' && prop !== 'theme' && prop !== 'sx' && prop !== 'as';
}

Material UI's root-level filter at rootShouldForwardProp.ts adds one more filter:

const rootShouldForwardProp = (prop: string) =>
  slotShouldForwardProp(prop) && prop !== 'classes';

The classes prop is filtered at root level because it's consumed by the utility class system, not by the DOM element.

CSS Class Generation System

Material UI generates deterministic CSS class names for every component slot and state. The system has three stages.

Stage 1: Define class tokens. Each component declares its slots in a classes file — buttonClasses.ts:

export function getButtonUtilityClass(slot: string): string {
  return generateUtilityClass('MuiButton', slot);
}

const buttonClasses = generateUtilityClasses('MuiButton', [
  'root', 'text', 'outlined', 'contained', 'focusVisible',
  'disabled', 'sizeMedium', 'sizeSmall', 'sizeLarge', ...
]);

Stage 2: Generate class names. The generateUtilityClass function at packages/mui-utils/src/generateUtilityClass/generateUtilityClass.ts produces either a component-scoped class or a global state class:

export default function generateUtilityClass(
  componentName: string,
  slot: string,
  globalStatePrefix = 'Mui',
): string {
  const globalStateClass = globalStateClasses[slot as GlobalStateSlot];
  return globalStateClass
    ? `${globalStatePrefix}-${globalStateClass}`   // e.g., "Mui-disabled"
    : `${ClassNameGenerator.generate(componentName)}-${slot}`;  // e.g., "MuiButton-root"
}

Global states like disabled, focusVisible, selected, and expanded use a shared Mui- prefix. This is a deliberate design: it means you can target &.Mui-disabled in your styles regardless of which component is disabled.

Stage 3: Compose with user classes. The composeClasses function at packages/mui-utils/src/composeClasses/composeClasses.ts merges generated classes with the user's classes prop:

for (const slotName in slots) {
  const slot = slots[slotName];
  let buffer = '';
  for (let i = 0; i < slot.length; i += 1) {
    const value = slot[i];
    if (value) {
      buffer += (start ? '' : ' ') + getUtilityClass(value);
      if (classes && classes[value]) {
        buffer += ' ' + classes[value];
      }
    }
  }
  output[slotName] = buffer;
}

Note the manual string concatenation with a buffer instead of Array.join() — this is a performance micro-optimization for a hot path.

flowchart TD
    Slots["slots = { root: ['root', 'contained', 'sizeMedium'] }"]
    Gen["getButtonUtilityClass('root') → 'MuiButton-root'"]
    User["classes = { root: 'my-custom-class' }"]
    Result["'MuiButton-root MuiButton-contained MuiButton-sizeMedium my-custom-class'"]

    Slots --> Gen
    Gen --> Result
    User --> Result

The useUtilityClasses hook in Button at line 20 ties this all together:

const useUtilityClasses = (ownerState) => {
  const { color, disableElevation, fullWidth, size, variant, loading, ... } = ownerState;

  const slots = {
    root: [
      'root',
      loading && 'loading',
      variant,
      `size${capitalize(size)}`,
      `color${capitalize(color)}`,
      disableElevation && 'disableElevation',
      fullWidth && 'fullWidth',
    ],
    startIcon: ['icon', 'startIcon'],
    endIcon: ['icon', 'endIcon'],
    loadingIndicator: ['loadingIndicator'],
  };

  return composeClasses(slots, getButtonUtilityClass, classes);
};

The slot arrays use conditional entries (loading && 'loading') — falsy values are silently filtered by composeClasses.

Styled Sub-Components and overridesResolver

Button defines five styled sub-components: ButtonRoot, ButtonStartIcon, ButtonEndIcon, ButtonLoadingIndicator, and ButtonLoadingIconPlaceholder. Each is created with styled() and the name/slot metadata that integrates it with the expression pipeline from Part 2.

The overridesResolver at line 80 is a function that maps theme style override keys to this specific sub-component:

overridesResolver: (props, styles) => {
  const { ownerState } = props;
  return [
    styles.root,
    styles[ownerState.variant],
    styles[`size${capitalize(ownerState.size)}`],
    ownerState.color === 'inherit' && styles.colorInherit,
    ownerState.disableElevation && styles.disableElevation,
    ownerState.fullWidth && styles.fullWidth,
    ownerState.loading && styles.loading,
  ];
},

When a user defines theme.components.MuiButton.styleOverrides.contained, the styleThemeOverrides tail expression (from Part 2) processes all override slots and passes the resolved object to this overridesResolver. The resolver picks which slots apply to the Root sub-component based on the current ownerState.

classDiagram
    class ButtonRoot {
        name: MuiButton
        slot: Root
        overridesResolver()
    }
    class ButtonStartIcon {
        name: MuiButton
        slot: StartIcon
        overridesResolver()
    }
    class ButtonEndIcon {
        name: MuiButton
        slot: EndIcon
        overridesResolver()
    }
    class ButtonLoadingIndicator {
        name: MuiButton
        slot: LoadingIndicator
    }
    class ButtonBase {
        name: MuiButtonBase
        slot: Root
    }

    ButtonRoot --> ButtonBase : extends via styled()

Tip: When debugging style overrides that aren't applying, check the overridesResolver for the target sub-component. The key you use in styleOverrides (e.g., 'startIcon') must match what the resolver returns. Most components define a defaultOverridesResolver that simply maps slot to styles[slot].

The Render Function

The render function at line 583 composes everything:

return (
  <ButtonRoot
    ownerState={ownerState}
    className={clsx(contextProps.className, classes.root, className, positionClassName)}
    component={component}
    disabled={disabled || loading}
    ref={ref}
    {...other}
    classes={classes}
  >
    {startIcon}
    {loadingPosition !== 'end' && loader}
    {children}
    {loadingPosition === 'end' && loader}
    {endIcon}
  </ButtonRoot>
);

Several details merit attention. The disabled prop is disabled || loading — loading implicitly disables the button. The classes prop is forwarded to ButtonRoot (which is actually ButtonBase), allowing class names to cascade to the base component. And {...other} spreads remaining props to the root, enabling DOM attributes like onClick, aria-*, and data-* to pass through.

What's Next

We've now seen how a component is built from the inside out: props resolution, ownerState construction, class generation, styled sub-components, and final render. In Part 4, we'll examine the theme system that powers all those (theme.vars || theme).palette.primary.main references — the dual-path architecture supporting both classic JS object themes and CSS custom properties.