[material-ui] POC: public CSS variables to opt out of Material Design#48568
Draft
siriwatknp wants to merge 12 commits into
Draft
[material-ui] POC: public CSS variables to opt out of Material Design#48568siriwatknp wants to merge 12 commits into
siriwatknp wants to merge 12 commits into
Conversation
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.
Deploy previewhttps://deploy-preview-48568--material-ui.netlify.app/ Bundle size
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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)
Two kinds of waste:
DENSITY_STEPS, three maps, three module augmentations) invented just to make spacing adaptive..Mui-disabledblocks, 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:
--Button-contained-bg/--Button-small-padding; the private--variant-*(spec defaults) stays private.var(--Button-bg, var(--variant-containedBg)). Unset → falls through to Material. Set → you win.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-disabledblocks exist precisely becausestyleOverridescan'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)
Net: density token group gone (coarse density rides existing
--mui-spacing), three per-variant.Mui-disabledblocks gone (identity cascades), focus ring centralized into--Button-ring,borderRadius→ var.opacity: 0.4fade stays one root line.Benefits
--variant-*, fall back to current spec when unset → default rendering identical. A minor-version feature, not a major.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.
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:
Walk the axes; per axis, expose one var per property the component actually paints/sizes. Two rules:
--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
--{Component}-{property}, kebab-case, no variant/size/color/state token.var(--Button-bg, var(--variant-containedBg))— unset falls through to Material; private--variant-*defaults stay private.--OutlinedInput-bg: var(--TextField-bg)), never reverse.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:
xxs…xxl) not reproduced. POC removes the plumbing (no module augmentation, no custom token group for coarse density) via single--mui-spacingdial. Arbitrary named steps = future "spacing token" layer; slots in underneath, no component change.--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):
var(--X, <default>)instead of a literal — at every state rule — plus the{Component}Vars.tsmaps. Bigger CSS/JS output. Mitigations: scope to the five axes (main properties only, not every declaration); repeatedvar(--Button-…prefixes gzip well; zero runtime cost.Scope of this PR
Proof on the two structurally hardest cases:
TextField → OutlinedInput → InputBasevar 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 viastyleOverrides; 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.