Skip to content

[material-ui] POC: public CSS variables to opt out of Material Design#48568

Draft
siriwatknp wants to merge 12 commits into
mui:masterfrom
siriwatknp:poc/css-vars-map
Draft

[material-ui] POC: public CSS variables to opt out of Material Design#48568
siriwatknp wants to merge 12 commits into
mui:masterfrom
siriwatknp:poc/css-vars-map

Conversation

@siriwatknp
Copy link
Copy Markdown
Member

@siriwatknp siriwatknp commented May 25, 2026

Status: POC. Ask: approve the contract below. Button + outlined inputs = proof on the two hardest cases. Library-wide rollout = tracked follow-up, reviewable per-component.

Preview: https://deploy-preview-48568--material-ui.netlify.app/experiments/agnostic-variables

TL;DR

Expose one agnostic, hand-authorable CSS var per styleable property (--Button-bg, --Button-color, --Button-border-color, --Button-radius, --Button-shadow, --Button-ring, --InputBase-height, …). Each defaults to Material spec. Set it → opts that one property out of spec, in every state. No --mui- prefix, no variant/size/color/state in name.

Payoff: customizations that today re-fight Material across every variant and every state collapse to setting a var.

The problem

No cheap opt-out today. Only path: styleOverrides / styled(). Core's state rules (hover, disabled, error, focusVisible) hardcode spec values → resting override clobbered the moment state changes. So you re-state intent in every variant × every state.

Not hypothetical. Real custom theme, built to fix four a11y gaps — focus ring, identity-preserving disabled, hover/pressed feedback, touch targets:

Before — the custom theme (click to expand)
// BEFORE — a whole density token system + re-stating .Mui-disabled in every variant
declare module '@mui/material/styles' {
  interface Theme {
    density: DensityTokens;
  }
  interface ThemeOptions {
    density?: DensityTokens;
  }
  interface ThemeVars {
    density: DensityTokens;
  }
}
const MEDIUM_DENSITY = {
  xxs: '4px',
  xs: '8px',
  s: '12px',
  m: '16px',
  l: '24px',
  xl: '32px',
  xxl: '48px',
};
// + HIGH_DENSITY, LOW_DENSITY maps

createTheme({
  cssVariables: true,
  density: MEDIUM_DENSITY, // ← custom token group, just to get adaptive spacing
  components: {
    MuiButton: {
      styleOverrides: {
        root: ({ theme }) => ({
          textTransform: 'none',
          fontWeight: 600,
          borderRadius: 6,
          paddingBlock: theme.vars.density.xxs,
          paddingInline: theme.vars.density.xs,
          '&.Mui-focusVisible': { outline: `2px solid ${BLUE}`, outlineOffset: 2 }, // ← duplicated in Checkbox
          '&.Mui-disabled': { opacity: 0.4 },
        }),
      },
      variants: [
        {
          props: { variant: 'outlined' },
          style: ({ theme }) => ({
            color: theme.vars.palette.text.primary,
            borderColor: theme.vars.palette.divider,
            '@media (hover:hover)': {
              '&:hover': {
                backgroundColor: theme.vars.palette.action.hover,
                borderColor: theme.vars.palette.divider,
              },
            },
            '&:active': { backgroundColor: theme.vars.palette.action.selected },
            '&.Mui-disabled': {
              color: theme.vars.palette.text.primary,
              borderColor: theme.vars.palette.divider,
            }, // ← re-state #1
          }),
        },
        {
          props: { variant: 'text' },
          style: ({ theme }) => ({
            color: theme.vars.palette.text.primary,
            '@media (hover:hover)': {
              '&:hover': { backgroundColor: theme.vars.palette.action.hover },
            },
            '&:active': { backgroundColor: theme.vars.palette.action.selected },
            '&.Mui-disabled': { color: theme.vars.palette.text.primary }, // ← re-state #2
          }),
        },
        {
          props: { variant: 'contained' },
          style: ({ theme }) => ({
            backgroundColor: theme.vars.palette.primary.main,
            color: '#fff',
            '@media (hover:hover)': {
              '&:hover': { backgroundColor: theme.darken(theme.palette.primary.main, 0.15) },
            },
            '&:active': { backgroundColor: theme.darken(theme.palette.primary.main, 0.3) },
            '&.Mui-disabled': { backgroundColor: theme.vars.palette.primary.main, color: '#fff' }, // ← re-state #3
          }),
        },
      ],
    },
  },
});

Two kinds of waste:

  • Bespoke density token group (DENSITY_STEPS, three maps, three module augmentations) invented just to make spacing adaptive.
  • Three .Mui-disabled blocks, one reason each: core's disabled rule overwrites variant colors with gray, userland can't reach that rule → theme re-asserts identity per variant.

The proposal — the contract

For a property to be re-expressible in another design language, core must do three things with it: name a single knob for it, make that knob own the value (spec = fallback), and read the knob in every state. None of these is doable from userland. The contract:

  1. One agnostic var per property — follows the five axes below, unopinionated about variant/size/color. So: one knob per styleable property, not per variant/size/color slot. Not --Button-contained-bg / --Button-small-padding; the private --variant-* (spec defaults) stays private.
  2. Layered over spec — the var owns the value, spec is the fallback: var(--Button-bg, var(--variant-containedBg)). Unset → falls through to Material. Set → you win.
  3. Read in every state — every state rule reads var(--X, <that state's spec value>), so a set var wins everywhere: resting, hover/active/focus, and disabled/error/disableElevation. Exception is the bug: a literal in any state rule clobbers the var.

Point 3 = the whole game, and the answer to "why not a docs recipe?"

Why this must live in core

Only core can make a var win in disabled/error/hover — those rules live in core, hardcode the spec value. Userland can't intercept them without re-stating every state = the sprawl above. The theme above proves it: its three .Mui-disabled blocks exist precisely because styleOverrides can't reach core's disabled rule. This PR routes that rule through the var → re-statements unnecessary.

After — same intent via agnostic vars (click to expand)
// AFTER — reuse --mui-spacing for density; set vars inside each variant; they cascade into disabled/error for free
createTheme({
  cssVariables: true, // emits --mui-spacing; override at any scope for density. No custom token group.
  components: {
    MuiButton: {
      styleOverrides: {
        root: {
          textTransform: 'none',
          fontWeight: 600,
          '--Button-radius': '6px',
          '--Button-ring': `2px solid ${BLUE}`, // outline; offset stays a per-component literal for now
          outlineOffset: 2,
          '&.Mui-disabled': { opacity: 0.4 },
        },
      },
      variants: [
        {
          props: { variant: 'outlined' },
          style: ({ theme }) => ({
            '--Button-color': theme.vars.palette.text.primary,
            '--Button-border-color': theme.vars.palette.divider,
            '@media (hover:hover)': {
              '&:hover': { '--Button-bg': theme.vars.palette.action.hover },
            },
            '&:active': { '--Button-bg': theme.vars.palette.action.selected },
            // no .Mui-disabled block — color + border cascade through the vars
          }),
        },
        {
          props: { variant: 'contained' },
          style: ({ theme }) => ({
            '--Button-bg': theme.vars.palette.primary.main,
            '--Button-color': '#fff',
            '@media (hover:hover)': {
              '&:hover': { '--Button-bg': theme.darken(theme.palette.primary.main, 0.15) },
            },
            '&:active': { '--Button-bg': theme.darken(theme.palette.primary.main, 0.3) },
            // no .Mui-disabled block
          }),
        },
        // text: same — set --Button-color, drop the .Mui-disabled re-state
      ],
    },
  },
});

Net: density token group gone (coarse density rides existing --mui-spacing), three per-variant .Mui-disabled blocks gone (identity cascades), focus ring centralized into --Button-ring, borderRadius → var. opacity: 0.4 fade stays one root line.

Benefits

  • No breaking changes → ships in v9.x. Additive: vars layer over the existing private --variant-*, fall back to current spec when unset → default rendering identical. A minor-version feature, not a major.
  • Less effort to opt out of Material. Before/after above: a property reset that today spans every variant × state collapses to one var set — and survives every state for free.
  • Design-agnostic → smoother v10. Public knobs are neutral to any design language. v10 can shift the spec defaults (or the design system) underneath without breaking the var contract. Teams already on the vars transition with little churn.

How agnostic variables work

Component reads one knob per property. Material Design = the default filling those knobs (vars unset → spec). Any other design language plugs into the same sockets by writing the vars — no component-code change, holds in every state. Anatomy = not a socket.

                    ┌────────────────────────────────────────────┐
                    │ COMPONENT   (Button, TextField, …)         │
                    │ reads one knob per property                │
                    └────────────────────────────────────────────┘
                                           │
                                           │ reads
                                           ▼
┌───────────────────────────────────────────────────────┐     ┌────────────────────────┐
│   AGNOSTIC VAR LAYER  (the sockets)                   │     │ MATERIAL DESIGN        │
│ geometry · density · elevation · color · typography   │◄────│ = DEFAULT              │
│ --Button-bg  --Button-radius  --Button-ring  …        │     │ vars unset → spec      │
│ → read in EVERY state                                 │     │ values (--variant-*)   │
└───────────────────────────────────────────────────────┘     └────────────────────────┘
                 ▲ writes                         ▲ writes
                 │                                │
                 │                                │
    ┌────────────────────────┐      ┌──────────────────────────┐
    │ design language A      │      │ design language B        │
    │ (e.g. flat/brand)      │      │ (e.g. high-density)      │
    │ set vars → re-expr     │      │ set vars → re-expr       │
    └────────────────────────┘      └──────────────────────────┘

Anatomy (label float, notch, ripple)  ─╳─  not a socket → props / variants / slots

What it is

Component-scoped CSS custom property naming exactly one styleable property, nothing else — no variant/size/color/state in name (--Button-bg, not --Button-contained-bg). Neutral to any design language: knows "this component has a background," not "Material has a contained variant," not "hover looks like this" (state = design language).

Not a CSS property with a nicer name. Lifts the property up a layer → component re-expressible in a different design language, reset to a neutral baseline. Raw CSS override changes one declaration, gets clobbered by Material's variant/state rules. Agnostic var replaces Material's value for that property everywhere, because the component reads the var in every state — every state rule reads var(--X, <that state's spec value>), none hardcode a value that overrides it. Var stays stateless: express a hover/pressed delta by reassigning it in a state selector (&:hover { --Button-bg: … }), as above.

The five axes — how you determine a component's vars

Agnostic vars cover the main properties to express a base design language, not every tweak. Sort into five axes; every var belongs to exactly one:

Axis Covers
geometry width, height, radius, border-width
density margin, padding, gap
elevation shadow, transform
color color, bg, border-color, ring
typography font-size, line-height, font-weight, text-transform, letter-spacing

Walk the axes; per axis, expose one var per property the component actually paints/sizes. Two rules:

  • One var per underlying property — collapse the variant × size × color × state matrix.
  • Only expose what the component paints/sizes — no --TextField-bg, outlined InputBase paints no fill.

Var fits no axis = smell → probably anatomy/behavior, not design-language value (see boundary below).

Rules and scope

  • Naming: --{Component}-{property}, kebab-case, no variant/size/color/state token.
  • Layered over spec: var(--Button-bg, var(--variant-containedBg)) — unset falls through to Material; private --variant-* defaults stay private.
  • Read in every state — no state rule hardcodes a value overriding the var.
  • Composition = inward dependency rule — component references only its own vars + vars of components it renders/extends; parents remap children (--OutlinedInput-bg: var(--TextField-bg)), never reverse.
  • Scope = ordinary CSS — set in styleOverrides, on a wrapper, in a variant selector, in @media, or inline; cascades/inherits normally.

What agnostic variables don't do — by design

Agnostic vars = baseline-reset primitive, not total customization API. Floor, not ceiling — that scoping is the point. Slice they cover = exactly the slice userland can't reach: resetting a property consistently across every state. Past the baseline (structural/behavioral, bespoke per-variant art) stays with styleOverrides / variants / slots, and agnostic vars compose with them. Small systematic surface (five axes) opening a capability — re-expressing a component's base design — beats an unbounded variant×state API.

Against this custom theme specifically, POC does not erase the whole file:

  • Named 7-step density scale (xxs…xxl) not reproduced. POC removes the plumbing (no module augmentation, no custom token group for coarse density) via single --mui-spacing dial. Arbitrary named steps = future "spacing token" layer; slots in underneath, no component change.
  • Intentional hover/pressed deltas stay hand-authored. A delta ("darken 15% on hover") is still yours — reassign the var in the state selector. POC removes the state re-statements you don't want, not the design intent you do.
  • Variants survive. Var set globally flattens across variants; scoped inside a variant selector (as above) keeps per-variant looks and gets the free state-cascade. Opt into per-variant by scoping.
  • Customizing forgoes spec's state styling for that property. Set --Button-bg → disabled gray-out / error red / elevation-off no longer apply to bg (the value you set wins in every state, by design — a var that wins in some states and silently loses in others = the real footgun). Want Material's state machine + only shift the base color? Remap the source — --mui-palette-* / --mui-spacing — not the component var. Two separated levers: component var = opt out; palette/spacing token = stay in spec, shift inputs.

The boundary — look vs. anatomy

Some opt-outs aren't a value on any axis. Outlined input floating label opt-out = anatomy/behavior — label position transform + notch cut in the outline — not a property value. Five-axis test correctly excludes it; the test working, not a gap.

Anatomy → complementary mechanism: prop/variant (and/or slot), which changes DOM + semantics a CSS var can't (var could zero the notch visually, but <legend> stays vestigial, label a11y position unaddressed). Two layers, composing not competing: agnostic vars = design language's look; props/variants/slots = its anatomy. Structural opt-outs out of POC scope — noted only to draw the line.

Tradeoffs

Real costs of adopting this (the "read in every state" behavior above is by design, not a cost):

  1. Bundle size. Every var-backed property emits var(--X, <default>) instead of a literal — at every state rule — plus the {Component}Vars.ts maps. Bigger CSS/JS output. Mitigations: scope to the five axes (main properties only, not every declaration); repeated var(--Button-… prefixes gzip well; zero runtime cost.
  2. API surface. Each agnostic var = a public contract to document, keep stable, not break across versions. Agnostic naming keeps this far smaller than per-variant/size/color slots, but library-wide rollout still adds new public surface. Mitigations: one knob per property bounded by the five axes; adding one is a deliberate act (ADR-0002).

Scope of this PR

Proof on the two structurally hardest cases:

  • Button — direct-padding density; full color/border/radius/shadow/ring surface.
  • Outlined inputsTextField → OutlinedInput → InputBase var mapping (inward dependency rule), height-driven vertical size with derived padding → font-size/density recompose for free.

Deferred (documented): FilledInput / standard Input / Select, font-weight, multiline + adornment padding, named spacing-token scale.

Try it

docs/pages/experiments/agnostic-variables.tsx — Before/After side by side: two themes targeting the same look. Left re-fights Material in every variant × state via styleOverrides; right sets one agnostic var per property. Same Buttons render identically (hover/focus/disabled), right with far less code — each column shows its live demo + the theme snippet below.

Design docs

  • CONTEXT.md — glossary + every resolved decision.
  • docs/adr/0001-public-css-var-inward-dependency.md — fallback chains reference inward only.
  • docs/adr/0002-agnostic-public-css-vars.md — one agnostic var per property; five axes; read in every state.
  • docs/design/public-css-var-layering.md — mechanics, worked input example, rejected alternatives.

Expose hand-authorable padding/height CSS vars on Button, InputBase,
OutlinedInput, TextField; rewire to theme.spacing() so --mui-spacing
drives holistic density. Inputs are height-driven (padding derived).
Adds CONTEXT.md, ADR-0001, layering design doc, experiment page.
--Button-font-size on all sizes; inputs via --InputBase-font-size with
variant chain --OutlinedInput-font-size and TextField mapping
--TextField-font-size. Input height formula's 1em tracks font-size, so
text re-centers in the fixed height automatically. Adds responsive
font-size experiment section; switch demo rows to useFlexGap.
…vars

--Button-bg/color/border-color/border-width/radius/shadow, layered over
private --variant-* spec source; resting-only. --Button-border-width also
drives outlined padding compensation. Adds ADR-0002 (agnostic public vars
are the public API contract).
--TextField-color/border-color/border-width/radius mapped to variant-level
--OutlinedInput-* (inward rule), consumed by NotchedOutline slot + root.
Border-width focus uses var(--OutlinedInput-border-width, 2px): default
1px->2px preserved, focus = resting when customized. No --TextField-bg
(InputBase styles no background). Resting-only.
@code-infra-dashboard
Copy link
Copy Markdown

code-infra-dashboard Bot commented May 25, 2026

Deploy preview

https://deploy-preview-48568--material-ui.netlify.app/

Bundle size

Bundle Parsed size Gzip size
@mui/material 🔺+3.87KB(+0.76%) 🔺+887B(+0.60%)
@mui/lab 0B(0.00%) 0B(0.00%)
@mui/private-theming 0B(0.00%) 0B(0.00%)
@mui/system 0B(0.00%) 0B(0.00%)
@mui/utils 0B(0.00%) 0B(0.00%)

Details of bundle changes


Check out the code infra dashboard for more information about this PR.

Opt-in focus ring via outline (default 0 = no change), separate from the
elevation box-shadow.
Centralize public var names in ButtonVars/InputBaseVars/OutlinedInputVars/
TextFieldVars; components reference them instead of literals. Private
--variant-* left inline. Dedupe input padding into derivedInputPadding helper.
Lead with 'leaving Material' triptych (stock | vars-only | +raw CSS),
Steel UI matrix across variants/palettes, disabled shown going grey as
documented limitation. Demote everyday knobs, fence density as separate axis.
…te style

Fix bug: hover/active/focus boxShadow ignored --Button-shadow; OutlinedInput
hover/focus borderColor ignored --OutlinedInput-border-color. State rules now
route through the var (spec value as fallback); disabled/error/disableElevation
stay literal. Reformat files to repo single-quote style. Update ADR-0002/CONTEXT.
…gh agnostic vars

Apply the agnostic-var rule to ALL assignments with no exceptions: Button
disabled color/bg/shadow, outlined disabled border, color=inherit color/border,
disableElevation shadow; InputBase disabled color; OutlinedInput error/disabled
notched-outline border. A custom var now wins in every state. Update ADR-0002/CONTEXT.
@siriwatknp siriwatknp changed the title [material-ui] POC: public agnostic CSS variables + density [material-ui] POC: public CSS variables to opt out of Material Design May 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant