Anatomy of a Material UI Component: From Props to Pixels
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:
slotsandcomponentsare shallow-merged (not overridden)slotPropsandcomponentsPropsare recursively merged per-slot- Other props: explicit props win over defaults (
undefinedmeans "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
overridesResolverfor the target sub-component. The key you use instyleOverrides(e.g.,'startIcon') must match what the resolver returns. Most components define adefaultOverridesResolverthat simply mapsslottostyles[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.