You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Material UI colors interaction states — hover, active, selected, disabled — one fixed way: Material Design 2. Teams building their own design system on top hit three walls.
Hard to customize. The production rules are hard-coded per component and inconsistent with each other. Four mechanisms coexist:
a palette[color].dark shift (Button contained, Chip, Fab, PaginationItem, ButtonGroup);
an alpha(…, hoverOpacity) tint (13 components);
a flat action.hover color (7 components);
no state color at all (Tab, AccordionSummary, OutlinedInput, FilledInput…).
There is no :active state anywhere. The only global surface — palette.action.* and palette[color].light/.dark — is flat (one hover color for the whole app), absolute (an rgba constant that cannot move away from the page background), and partial (Button reads none of action.hover). So every correction goes through per-component styleOverrides, per variant, per scheme.
The clearest evidence the mechanism is missing: the library itself has nowhere to put one. This string is hand-written, character for character, 14 times across 7 components:
Composing "selected" with "hover" is a rule. With no place to store a rule, it gets copied.
No opt-out.styleOverrides can only pile CSS on top — the Material Design rules still render underneath and still ship in the stylesheet. Concrete case: a design system whose disabled state is { opacity: 0.5 } cannot remove Material UI's disabled greys:
// Button.js — hard-coded for every theme[`&.${buttonClasses.disabled}`]: {color: (theme.vars||theme).palette.action.disabled,backgroundColor: (theme.vars||theme).palette.action.disabledBackground,},
Material UI today
The design system's target
To get the right column, the theme must override the grey with stronger CSS on every component and every variant — and the grey styles still ship regardless. Overriding is not opting out.
Opinionated variant styles. Material Design 2 hard-codes what each variant means — contained hovers darker, text/outlined hover as an alpha tint of the same color:
// Button.js — the meaning of "hover" per variant, fixed'&:hover': {'--variant-containedBg': (theme.vars||theme).palette[color].dark,'--variant-textBg': theme.alpha((theme.vars||theme).palette[color].main,theme.palette.action.hoverOpacity),'--variant-outlinedBg': theme.alpha((theme.vars||theme).palette[color].main,theme.palette.action.hoverOpacity),},
A design system with its own set of looks — solid, ghost, navigation row, feedback surface — does not fit into those rules. The same palette color legitimately needs different state values in different contexts, and the theme has no way to say so.
What are the requirements?
Light & dark from one configuration, SSR-compatible: state values ride the same per-scheme CSS-variables pipeline as palette; a scheme flip is pure CSS, no flash, no runtime recompute.
Non-breaking. An unconfigured theme renders byte-identical CSS — proven by serialized-CSS diff, not assumed.
True opt-out. Where the theme takes over, the stock styling stops being emitted — replaced, not overridden.
Minimal CSS. Output scales with the tokens the design system authors, never with the component count. No per-component variable grids.
Acceptable JS increment: plain data plus one shared resolver, no runtime color math.
Incremental adoption — per component family, per variant, per color. Anything not opted in keeps today's styles unchanged.
Proposal
Material UI is rightly opinionated: Material Design is the default look and feel, and that stays. But a custom design system using Material UI as a building block needs a way to opt out of those opinions — and the solution itself must not be opinionated, or it brings back the same problem one level up: any state naming or variant mapping the library picks becomes the next thing a design system has to fight.
Being unopinionated is also forced by the components themselves. The variant vocabulary differs per component — Button says text / outlined / contained, Alert and TextField say standard / filled / outlined, Chip says filled / outlined, PaginationItem says text / outlined, and many stateful components (MenuItem, ListItemButton, TableRow) have no variant prop at all — and a design system might have its own terms (solid, ghost, navigation, feedback) that match none of them.
So instead of shipping another opinion — what if the user could name their own states, define the exact styles for each one, and tell each component which of its variants uses which state? The library ships only the wiring: a per-scheme state node of user-named groups, and a per-component binding from the component's own variants to those groups.
Declaring states — the state node
A state group is a named set of palette-color entries; each entry holds handpicked styles per interaction state, authored per scheme:
createTheme({cssVariables: true,colorSchemes: {light: {state: {input: {// group names are the user's, not the library'sprimary: {initial: {backgroundColor: '#006DA2',color: '#FFFFFF'},hover: {backgroundColor: '#006698'},active: {backgroundColor: '#005F8E'},disabled: {opacity: 0.5},},},ghost: {primary: {initial: {color: '#363636',borderColor: 'rgba(0, 0, 0, 0.15)'},hover: {backgroundColor: 'rgba(0, 0, 0, 0.05)'},},},},},dark: {state: {/* same group names, that scheme's values */}},},});
States per entry — each an optional, plain, spreadable React.CSSProperties; an absent state or property emits no CSS. Ordered by precedence: a state lower in the table overrides the ones above it when both apply.
Entry keys mirror the palette (every key whose value is a PaletteColor, plus default), enforced by TypeScript — the key type is mapped from Palette, so an augmented custom color (palette.brand) is accepted automatically. There is no runtime validation: components generate rules per palette color, so an entry that matches no palette color simply never renders.
A group is a look. A quiet (text/outlined) button is not a "soft state" of the solid color — it is a different look with its own rest, foreground, and border, so it is a group the user names (ghost), invisible to the group the solid variant reads (input). Two groups can hold different values for the same (color, state, channel) — this is what removes the conflict that broke the earlier flat-node attempt (see "What did we try?" below).
The node follows palette's full pipeline: per scheme, shipped as CSS variables under each scheme's selector — --mui-state-input-primary-hover-backgroundColor — mirrored on theme.vars.state. One set of generated component CSS serves every scheme; a scheme flip is pure CSS (requirement 1).
Binding variants — stateVariants
The user maps each component's own variant values to group names:
components: {MuiButton: {stateVariants: {contained: 'input',outlined: 'ghost',text: 'ghost'}},MuiAlert: {stateVariants: {standard: 'feedback',outlined: 'feedback'}},// different vocabulary, same groupsMuiMenuItem: {stateVariants: {default: 'navigation'}},// no variant prop → key `default`},
A record only — variant value → group name. A component without a variant prop uses the key default, so gaining a variant prop later needs no shape migration, only new keys. The binding is the opt-in: Material UI ships the engine and zero mappings; an unbound component keeps exactly today's styles.
How a component consumes a state
Say the user creates a state group like this and binds the MuiButton variant contained to input:
createTheme({cssVariables: true,colorSchemes: {light: {state: {input: {primary: {// the only color defined in the groupinitial: {backgroundColor: '#006DA2',color: '#FFFFFF'},hover: {backgroundColor: '#006698'},active: {backgroundColor: '#005F8E'},disabled: {opacity: 0.5},},},},},},components: {MuiButton: {stateVariants: {contained: 'input'}},},});
Step 1 — the component asks a shared resolver. In its own styles (the way it reads theme.focusVisible), the component resolves (variant, color) to an entry:
constcolorStates=resolveColorStates(theme,'MuiButton','contained','primary');// looks up the binding: stateVariants.contained → 'input'// then the entry: (theme.vars || theme).state.input.primary
Step 2 — no binding, nothing changes. If contained is not bound, the resolver returns undefined for every color and the stock block simply keeps matching — all of today's styles, untouched:
Step 3 — bound: the stock block switches off, flat rules take over. The gate above stops matching (requirement 3 — a true opt-out, not an override), and the component emits one rule per (variant, color) instead. For the configured primary, the rule is exactly what the entry declares — the component only decides the selectors:
The entry is all that renders: if the group has no hover, those buttons have no hover change. An absent state or property emits no CSS — never a fallback to some other value.
Step 4 — a color the group does not define keeps today's look.error is not in input, so its rule is a copy of the stock contained styles, scoped to that pair:
{props: {variant: 'contained',color: 'error'},style: mdContained}// mdContained = the same object the stock block uses — shared, not duplicated
So adoption is incremental per color (requirement 6): list primary only, and error buttons render exactly as today. It costs one extra rule per unconfigured color and no source duplication. Note there is no other fallback — a missing color never reads the group's default entry or another group; it keeps today's look, nothing else.
Components without color
Two axes can be missing, and each has its default key:
No variant prop (MenuItem, ListItemButton, TableRow) → the binding uses the key default.
No color prop — colourless consumers read the group's default entry, next to the palette-color keys:
A component with a colourless value on its color prop rides the same key: Chip's color="default" reads the bound group's default entry — the same entry a no-color component reads.
Component contract
Inside component source, states replace the stock look, never override it. Two shapes, chosen per family:
Variant switch-off + flat rules (Button) — a bound variant's stock block stops matching (ownerState.variant === X && !slotStates), and the component appends one direct-paint rule per configured (variant, color) to its variants array:
In-place switch (MenuItem) — configured ? new : stock inside the existing rules.
Selectors and media queries belong to the component, never to the data: configured hover is emitted inside @media (hover: hover) by the component; :active stays ungated. Border on non-outlined variants: the component appends border: 'none' unless the entry's initial authors the exact border shorthand — longhands (borderWidth, borderColor, borderLeft) never affect non-outlined variants; on outlined variants they refine the structural border ({ border: '1px solid', ...initial }). So a group shared between outlined and non-outlined slots can author longhands for the outlined side without leaking, while a solid design declares a border deliberately with the shorthand. Nothing registers styleOverrides, so that surface — and its precedence over library styles — stays fully the user's.
CSS & JS cost
CSS scales with authored tokens only (requirement 4): the full six-group demo theme emits 142 variable declarations (~1.5–2 KB gzipped) regardless of how many components bind. Converting all ten families removed all 14 hand-copied composition rules — the rule now lives in one place, the theme.
Q&A
Is this solution only about colors?
Not really. A state style is plain CSS, so anything works — box-shadow, border, opacity, transform. And the opt-out removes whatever the component's own state styles define: bind Button's contained variant and its built-in hover elevation (the box-shadow increase) is switched off together with the hover color, replaced by exactly what the entry declares. The only requirement is that the value can be generated as a CSS variable.
Do I have to define both light and dark?
Define your groups in every scheme, with each scheme's values. If a value exists in only one scheme, the other schemes reuse it (the CSS variable keeps the defined value) — it does not fall back to today's styles.
Does this replace palette.action.*?
No. palette.action.* stays untouched; every unbound component keeps reading it exactly as today.
Can I read the values in my own components or styleOverrides?
Yes. theme.state holds the literals and theme.vars.state holds the var() strings — the same read pattern as palette.
What did we try?
Three earlier iterations, each killed by evidence.
Every component exposes variables per state and slot:
--mui-Button-hoverBackground: …;
--mui-Button-activeBackground: …;
--mui-MenuItem-selectedBackground: …;
/* × every state × every slot × every component */
Rejected: the API surface is a variable per (component, slot, state) — the emitted CSS and the public surface scale with the component count, which cannot scale.
2. Derive states from the rest color (enhancer + generators)
An enhancer applied after createTheme derives hover/active from each palette color via a formula:
Killed twice over. Fitting all 71 solid rest → hover → active triads in a production token export: the per-level magnitude differs per color by up to 6.3× and again per scheme, and chroma usually increases along the ramp — unreachable when mixing toward a neutral pole. No formula fits; design systems handpick their state colors, and that flexibility is the requirement — the theme should store values, not formulas. And the enhancer sat at the wrong layer: injected styles must override each component's own :hover with stronger CSS — they can only add on top, the opt-out problem again.
3. One flat theme.states node (+ soft* states)
Handpicked values, one entry per palette color, components read them natively; quiet looks got parallel soft* state names:
createTheme({states: {primary: {hover: { … },softHover: { … }},// soft* = state names invented by the libraryerror: {hover: { … }},},});
Converting Alert broke it structurally: two components can demand different values for the same (color, state, channel) triple — Alert's standard variant rests on a pale severity fill while Button's contained variant rests on the solid fill. One shared error entry cannot say both, and every way out meant the library inventing more global state names — which is what soft* already was. This is the conflict the group model removes.
Prototype
Implemented in #49048: the engine (state node, CSS-variable emission, theme.vars.state mirror, stateVariants, the shared resolver, strict typing) plus ten converted families — Button, MenuItem, Alert, Chip, TextField (Input / FilledInput / OutlinedInput), ListItemButton, TableRow, Autocomplete (listbox options), PaginationItem, ToggleButton. All 14 hand-copied composition rules are removed; with no binding, the serialized CSS for every converted family is byte-identical to before.
Live demo — https://deploy-preview-49048--material-ui.netlify.app/experiments/states/: one mail app, three design systems. A working mail pane built from the converted components, with a look switcher (stock Material ↔ Neobrutalism ↔ Custom — two complete non-Material design languages sharing one group vocabulary: Neobrutalism authors shadows, borders, and transforms as states with opacity: 0.5 disabled; Custom comes from a production design-token export and uses no styleOverrides at all — stock structure, every interaction color from the state groups, light and dark each). A collapsible viewer prints the live state + stateVariants config powering the current look. Since Material is the unbound baseline, flipping the look switcher back to Material is the zero-config comparison.
Decision need
Should configured hover stay media-gated (@media (hover: hover)) by default, or become a lever?
Naming: the state node · stateVariants · resolveColorStates · the focused state name.
Group-name typing is strict via the Overrides augmentation pattern: StateGroupOverrides (augment { input: true; … }) types the state node keys and stateVariants values; stateVariants keys are the component's own variant union plus default. TypeScript users must augment to use the feature — confirm the trade.
Follow-up (deferred): remaining family conversions (ButtonGroup, IconButton, Fab, ListItem, SwitchBase controls); VRT pass once the conversion set is final
Prototype: #49048
What's the problem?
Material UI colors interaction states — hover, active, selected, disabled — one fixed way: Material Design 2. Teams building their own design system on top hit three walls.
Hard to customize. The production rules are hard-coded per component and inconsistent with each other. Four mechanisms coexist:
palette[color].darkshift (Button contained, Chip, Fab, PaginationItem, ButtonGroup);alpha(…, hoverOpacity)tint (13 components);action.hovercolor (7 components);There is no
:activestate anywhere. The only global surface —palette.action.*andpalette[color].light/.dark— is flat (one hover color for the whole app), absolute (an rgba constant that cannot move away from the page background), and partial (Button reads none ofaction.hover). So every correction goes through per-componentstyleOverrides, per variant, per scheme.The clearest evidence the mechanism is missing: the library itself has nowhere to put one. This string is hand-written, character for character, 14 times across 7 components:
`${(theme.vars || theme).palette.action.selectedOpacity} + ${(theme.vars || theme).palette.action.hoverOpacity}`Composing "selected" with "hover" is a rule. With no place to store a rule, it gets copied.
No opt-out.
styleOverridescan only pile CSS on top — the Material Design rules still render underneath and still ship in the stylesheet. Concrete case: a design system whose disabled state is{ opacity: 0.5 }cannot remove Material UI's disabled greys:To get the right column, the theme must override the grey with stronger CSS on every component and every variant — and the grey styles still ship regardless. Overriding is not opting out.
Opinionated variant styles. Material Design 2 hard-codes what each variant means — contained hovers darker, text/outlined hover as an alpha tint of the same color:
A design system with its own set of looks — solid, ghost, navigation row, feedback surface — does not fit into those rules. The same palette color legitimately needs different state values in different contexts, and the theme has no way to say so.
What are the requirements?
palette; a scheme flip is pure CSS, no flash, no runtime recompute.Proposal
Material UI is rightly opinionated: Material Design is the default look and feel, and that stays. But a custom design system using Material UI as a building block needs a way to opt out of those opinions — and the solution itself must not be opinionated, or it brings back the same problem one level up: any state naming or variant mapping the library picks becomes the next thing a design system has to fight.
Being unopinionated is also forced by the components themselves. The variant vocabulary differs per component — Button says
text / outlined / contained, Alert and TextField saystandard / filled / outlined, Chip saysfilled / outlined, PaginationItem saystext / outlined, and many stateful components (MenuItem, ListItemButton, TableRow) have no variant prop at all — and a design system might have its own terms (solid, ghost, navigation, feedback) that match none of them.So instead of shipping another opinion — what if the user could name their own states, define the exact styles for each one, and tell each component which of its variants uses which state? The library ships only the wiring: a per-scheme
statenode of user-named groups, and a per-component binding from the component's own variants to those groups.Declaring states — the
statenodeA state group is a named set of palette-color entries; each entry holds handpicked styles per interaction state, authored per scheme:
States per entry — each an optional, plain, spreadable
React.CSSProperties; an absent state or property emits no CSS. Ordered by precedence: a state lower in the table overrides the ones above it when both apply.initialhoveractive:active)focused.Mui-focused); focus rings remaintheme.focusVisible's domainselected.Mui-selected,aria-selected)selectedHoverselectedOpacity + hoverOpacityselectedActivedisabledEntry keys mirror the palette (every key whose value is a
PaletteColor, plusdefault), enforced by TypeScript — the key type is mapped fromPalette, so an augmented custom color (palette.brand) is accepted automatically. There is no runtime validation: components generate rules per palette color, so an entry that matches no palette color simply never renders.A group is a look. A quiet (text/outlined) button is not a "soft state" of the solid color — it is a different look with its own rest, foreground, and border, so it is a group the user names (
ghost), invisible to the group the solid variant reads (input). Two groups can hold different values for the same (color, state, channel) — this is what removes the conflict that broke the earlier flat-node attempt (see "What did we try?" below).The node follows
palette's full pipeline: per scheme, shipped as CSS variables under each scheme's selector —--mui-state-input-primary-hover-backgroundColor— mirrored ontheme.vars.state. One set of generated component CSS serves every scheme; a scheme flip is pure CSS (requirement 1).Binding variants —
stateVariantsThe user maps each component's own variant values to group names:
A record only — variant value → group name. A component without a variant prop uses the key
default, so gaining a variant prop later needs no shape migration, only new keys. The binding is the opt-in: Material UI ships the engine and zero mappings; an unbound component keeps exactly today's styles.How a component consumes a state
Say the user creates a state group like this and binds the MuiButton variant
containedtoinput:Step 1 — the component asks a shared resolver. In its own styles (the way it reads
theme.focusVisible), the component resolves (variant, color) to an entry:Step 2 — no binding, nothing changes. If
containedis not bound, the resolver returnsundefinedfor every color and the stock block simply keeps matching — all of today's styles, untouched:Step 3 — bound: the stock block switches off, flat rules take over. The gate above stops matching (requirement 3 — a true opt-out, not an override), and the component emits one rule per (variant, color) instead. For the configured
primary, the rule is exactly what the entry declares — the component only decides the selectors:The entry is all that renders: if the group has no
hover, those buttons have no hover change. An absent state or property emits no CSS — never a fallback to some other value.Step 4 — a color the group does not define keeps today's look.
erroris not ininput, so its rule is a copy of the stock contained styles, scoped to that pair:So adoption is incremental per color (requirement 6): list
primaryonly, anderrorbuttons render exactly as today. It costs one extra rule per unconfigured color and no source duplication. Note there is no other fallback — a missing color never reads the group'sdefaultentry or another group; it keeps today's look, nothing else.Components without color
Two axes can be missing, and each has its
defaultkey:default.defaultentry, next to the palette-color keys:A component with a colourless value on its color prop rides the same key: Chip's
color="default"reads the bound group'sdefaultentry — the same entry a no-color component reads.Component contract
Inside component source, states replace the stock look, never override it. Two shapes, chosen per family:
Variant switch-off + flat rules (Button) — a bound variant's stock block stops matching (
ownerState.variant === X && !slotStates), and the component appends one direct-paint rule per configured (variant, color) to itsvariantsarray:In-place switch (MenuItem) —
configured ? new : stockinside the existing rules.Selectors and media queries belong to the component, never to the data: configured hover is emitted inside
@media (hover: hover)by the component;:activestays ungated. Border on non-outlined variants: the component appendsborder: 'none'unless the entry'sinitialauthors the exactbordershorthand — longhands (borderWidth,borderColor,borderLeft) never affect non-outlined variants; on outlined variants they refine the structural border ({ border: '1px solid', ...initial }). So a group shared between outlined and non-outlined slots can author longhands for the outlined side without leaking, while a solid design declares a border deliberately with the shorthand. Nothing registersstyleOverrides, so that surface — and its precedence over library styles — stays fully the user's.CSS & JS cost
CSS scales with authored tokens only (requirement 4): the full six-group demo theme emits 142 variable declarations (~1.5–2 KB gzipped) regardless of how many components bind. Converting all ten families removed all 14 hand-copied composition rules — the rule now lives in one place, the theme.
Q&A
Is this solution only about colors?
Not really. A state style is plain CSS, so anything works —
box-shadow,border,opacity,transform. And the opt-out removes whatever the component's own state styles define: bind Button'scontainedvariant and its built-in hover elevation (thebox-shadowincrease) is switched off together with the hover color, replaced by exactly what the entry declares. The only requirement is that the value can be generated as a CSS variable.Do I have to define both light and dark?
Define your groups in every scheme, with each scheme's values. If a value exists in only one scheme, the other schemes reuse it (the CSS variable keeps the defined value) — it does not fall back to today's styles.
Does this replace
palette.action.*?No.
palette.action.*stays untouched; every unbound component keeps reading it exactly as today.Can I read the values in my own components or
styleOverrides?Yes.
theme.stateholds the literals andtheme.vars.stateholds thevar()strings — the same read pattern aspalette.What did we try?
Three earlier iterations, each killed by evidence.
1. Per-component state CSS variables (#48657)
Every component exposes variables per state and slot:
Rejected: the API surface is a variable per (component, slot, state) — the emitted CSS and the public surface scale with the component count, which cannot scale.
2. Derive states from the rest color (enhancer + generators)
An enhancer applied after
createThemederives hover/active from each palette color via a formula:Killed twice over. Fitting all 71 solid
rest → hover → activetriads in a production token export: the per-level magnitude differs per color by up to 6.3× and again per scheme, and chroma usually increases along the ramp — unreachable when mixing toward a neutral pole. No formula fits; design systems handpick their state colors, and that flexibility is the requirement — the theme should store values, not formulas. And the enhancer sat at the wrong layer: injected styles must override each component's own:hoverwith stronger CSS — they can only add on top, the opt-out problem again.3. One flat
theme.statesnode (+soft*states)Handpicked values, one entry per palette color, components read them natively; quiet looks got parallel
soft*state names:Converting Alert broke it structurally: two components can demand different values for the same (color, state, channel) triple — Alert's standard variant rests on a pale severity fill while Button's contained variant rests on the solid fill. One shared
errorentry cannot say both, and every way out meant the library inventing more global state names — which is whatsoft*already was. This is the conflict the group model removes.Prototype
Implemented in #49048: the engine (
statenode, CSS-variable emission,theme.vars.statemirror,stateVariants, the shared resolver, strict typing) plus ten converted families — Button, MenuItem, Alert, Chip, TextField (Input / FilledInput / OutlinedInput), ListItemButton, TableRow, Autocomplete (listbox options), PaginationItem, ToggleButton. All 14 hand-copied composition rules are removed; with no binding, the serialized CSS for every converted family is byte-identical to before.Live demo — https://deploy-preview-49048--material-ui.netlify.app/experiments/states/: one mail app, three design systems. A working mail pane built from the converted components, with a look switcher (stock Material ↔ Neobrutalism ↔ Custom — two complete non-Material design languages sharing one group vocabulary: Neobrutalism authors shadows, borders, and transforms as states with
opacity: 0.5disabled; Custom comes from a production design-token export and uses nostyleOverridesat all — stock structure, every interaction color from the state groups, light and dark each). A collapsible viewer prints the livestate+stateVariantsconfig powering the current look. Since Material is the unbound baseline, flipping the look switcher back to Material is the zero-config comparison.Decision need
hoverstay media-gated (@media (hover: hover)) by default, or become a lever?statenode ·stateVariants·resolveColorStates· thefocusedstate name.StateGroupOverrides(augment{ input: true; … }) types thestatenode keys andstateVariantsvalues;stateVariantskeys are the component's own variant union plusdefault. TypeScript users must augment to use the feature — confirm the trade.Resources and benchmarks
statestheme option #49048 — engine + Button, MenuItem, Alert, Chip, TextField (Input/FilledInput/OutlinedInput), ListItemButton, TableRow, Autocomplete, PaginationItem, ToggleButtontheme.focusVisible)