Skip to content

[material-ui] CSS-var color/state adapter experiment#48657

Draft
siriwatknp wants to merge 29 commits into
mui:masterfrom
siriwatknp:exp/css-var-color-state-tokens
Draft

[material-ui] CSS-var color/state adapter experiment#48657
siriwatknp wants to merge 29 commits into
mui:masterfrom
siriwatknp:exp/css-var-color-state-tokens

Conversation

@siriwatknp

@siriwatknp siriwatknp commented Jun 11, 2026

Copy link
Copy Markdown
Member

Stacks on #48624 (CSS-var density adapter, unmerged) — so the file diff here includes that branch. The new work is the 2 commits on top. Not intended to merge as-is; opened for the deploy preview and review.

The idea

Same philosophy as the density adapter (#48624), now on the color axis: let
designers tune component color — background / foreground / border, per
variant, per palette color, per interaction state (hover / focus / active /
disabled / selected) — via hand-authorable CSS variables, without editing
component source
. An un-configured theme renders today's exact colors
(Argos zero-diff).

Playground

Each has a sidebar with a Default preset (sets nothing → identical to today)
and a Brand preset that recolours per (variant, color, state) from one place.

Consumer usage

Scope a token at any level — no function needed. Unlike density there is no
enhanceColor
: the palette is color's holistic dial, and these tokens are
per-component-state deviations from it.

// every contained primary button inside is recoloured on hover
<Box sx={{ '--Button-contained-primary-hover-bg': '#1565C0' }}></Box>

Shape: --Button-<variant>-<color>-<state>-<prop> (rest omits <state>). A
single-axis component drops the segments it lacks — MenuItem is
--MenuItem-<state>-<prop>.

Technical approach

Three layers over the pre-existing --variant-* seam:

--Component-<variant>-<color>-<state>-<prop>   public knob (designer)
--variant-<variant><Prop>                      agnostic seam (root's consumption point)
--_<variant><Prop>                             private default (current resolved value)

Value-states (rest / hover / disabled — genuinely set the prop) carry their
token inside the default; inert states (focus / active — Button only moves
box-shadow) route over it. So an unset inert token tracks the live value, and a
rest/hover override propagates to every state.

Supported today

  • Button — variant × color × state, full standard state set; disabled
    tokenized (inherit/selected deferred).
  • MenuItem — single-axis; background per state (incl. compound
    selected:hover / selected:focus), inert foreground, and a border that rides
    an inset box-shadow ring so enabling it never shifts the menu's layout.

Design notes

  • Decision record: docs/adr/0002-css-var-color-state-adapter.md
  • Glossary (color-axis terms): CONTEXT.md

What's next

  • Agree the per-component color token set (which states/props each component owns).
  • Roll out beyond Button / MenuItem.
  • Selected / inherit color tokens; per-color disabled validation.

siriwatknp added 29 commits June 5, 2026 12:41
Expose Button padding as overridable CSS vars resolved inline from a
(variant,size) lookup; add enhanceDensity to wire tokens to a --mui-density-*
scale. Literal-px fallbacks keep the default pixel-identical. Design in
CONTEXT.md + docs/adr/0001; demo at /experiments/density-tokens.
… + --Button-pad seam

- Root consumes var(--Button-pad, var(--_pad)); --_pad universal default on root
- (variant,size) literals + built-in-size routing live in variants (deduped CSS)
- Inline bridge only for custom sizes (keeps custom sizes tunable, zero inline for built-ins)
- Two-var rationale + accepted trade-offs documented in ADR-0001 + CONTEXT
- enhanceDensity maps sized tokens (--Button-<size>-pad) to density scale
…seam

- OutlinedInput: block-only density (--OutlinedInput-<size>-padBlock); root routes, input inherits; drop redundant size/multiline variants
- InputLabel: generic --InputLabel-y seam; OutlinedInput bridges sibling label via :has(~ &)
- Docs: ADR-0001 OutlinedInput + label :has bridge, CONTEXT, density-adapter-rollout guide, experiment demo
- density-fixture.tsx: per-component matrix scoped by ?c=&level (default pixel-identical)
- scripts/density-screenshots: config + spec + README (maxDiffPixels 0)
- density:shot / density:shot:update scripts; gitignore harness outputs
- Tokenize the 14px inline gutter as --OutlinedInput-padInline (size-invariant base token)
- Uniform consume shape var(--seam, var(--_internal)) across both axes: block sized (routed), inline base; --_padInline internal default
- Docs: base-token shape in ADR/CONTEXT; rollout gotchas — split-only-if-forced, uniform consume shape, inline gutter != adornment gap
Revert the lift of block padding to the root + inheritance; tokenize each
literal where master has it (input owns inline/non-multiline, root owns
multiline/adornment gutters) for the smallest diff.

Promote padInline from a base token to a sized axis: default 14px both sizes,
but expose --OutlinedInput-<size>-padInline so a design system can tune inline
density per size. Both axes now routed per size in place; label :has derives
--InputLabel-y straight from the public sized token.

Docs: base token reserved for axes where per-size override is meaningless; a
size-invariant default alone no longer justifies it.
Apply the density adapter (docs/adr/0001) to the @mui/material components used
by the dashboard template: Chip, IconButton, MenuItem, ListItem, ListItemButton,
ListItemIcon, ListItemText, ListSubheader, Toolbar, Tab, Tabs, TablePagination,
CardContent, Select, Breadcrumbs, InputAdornment, Badge. Each exposes its real
spacing axes as public sized tokens over literal-px internal defaults; the
default render stays pixel-identical to master (density screenshot harness,
maxDiffPixels:0). Checkbox/FormControl skipped - no density axis.

enhanceDensity wires every component's sized tokens (incl. OutlinedInput) to the
density scale. The verification fixture gains a matrix + dense/loose scope per
component.

Boolean `dense` components (MenuItem, ListItem, ListItemButton, ListItemText)
expose the default state via the plain seam --Component-<key> and only the dense
override as --Component-dense-<key>. Toolbar keeps theme.mixins.toolbar for its
regular height (only dense + gutters tokenized).
Boolean compactness toggles (dense) use a state token: default state is the
plain seam --Component-<key> (base-token-shaped, no base routing), only the on
state is qualified --Component-dense-<key>. No --Component-normal/regular/default-
qualifier - a boolean has no name for off. Added to CONTEXT language, ADR 0001
resolution, and the rollout recipe + naming.
SwitchBase (shared agnostic base) consumes one seam: padding var(--SwitchBase-pad,
var(--_pad)), --_pad 9px. Checkbox/Radio (styled(SwitchBase)) route per-size
public tokens --Checkbox/Radio-<size>-pad into the seam; default 9px both sizes
(pixel-identical). Switch routes its thumb (SwitchBase) padding via --Switch-<size>-pad
(9/4); box geometry stays literal (size-coupled). enhanceDensity + fixture wired.
SwitchBase owns the agnostic seam consumed once; Checkbox/Radio/Switch route
per-component sized tokens into it. Covers the two reader topologies (consumer is
the base vs wraps it as a descendant), delivery via custom-property inheritance
(no descendant selector), and the --_<key>-shadowing caveat. Added to CONTEXT
relationships, ADR 0001 specifics, rollout Recipe C + Done list.
Tokenize Switch's four real dims per size (--Switch-<size>-width/height/thumbSize/
touchSize). Derive SwitchBase pad = (touchSize-thumbSize)/2, button top =
(height-touchSize)/2, checked travel = width-touchSize, thumb size = thumbSize, so
the thumb stays centered on the track (absolute + transform). Replaces the
pad-only token that drifted the thumb. Switch dropped from enhanceDensity (geometry
isn't spacing-scale-derived). Default pixel-identical.
…lues

Switch tokenizes width/height/thumbSize/touchSize per size and derives pad/top/
travel via calc (thumb stays centered); not the pad-only approach. Corrects the
shared-base sections in ADR 0001 + rollout Recipe C.
The root padding (12/7, track inset) is its own axis -> tokenize as
--Switch-<size>-pad over --_pad, consumed padding: var(--Switch-pad, var(--_pad)).
Distinct from the derived thumb SwitchBase pad. Fixture scope + docs updated.
borderRadius = (height - 2*pad)/2 (full-pill track thickness) instead of literal
14/2, so the track stays rounded when the dims are tuned. Pixel-identical (medium
7px; small clamps to a pill).
Add an xxl density step (4x spacing unit). Wire MuiSwitch: map per-size
width/height/touchSize/thumbSize/pad to scale steps (xxl for the wider track);
pad/top/travel/radius re-derive so the geometry stays valid. Docs updated.
Switch dims were mapped to single scale steps, shrinking it. Compose from steps so
defaults land on today's px (medium 58/38/20/38/12, small 40/24/16/24/7) and still
scale with density: width calc(xxl*2-6), height/touch calc(xxl+xs), thumb
calc(lg+xxs), etc. touchSize == height keeps the thumb centered.
…Tabs minHeight

- enhanceDensity: derive OutlinedInput --InputLabel-y from density step (sibling label can't read the input's padBlock token); per-size via variants
- MenuItem: consume --ListItemIcon-minWidth (was hardcoded 36) so density reaches the icon
- Tabs: add --Tabs-minHeight base seam (parent can't read child --Tab-minHeight) + wire MuiTabs
- New /experiments/density-showcase: preset switcher (compact/normal/comfort), live scale readout + per-component token accordion, masonry gallery
- Extract shared demos to densityDemos.tsx; fixture imports it
- Fixture: --Tabs-minHeight scope, center row Stacks
calc(var(--Chip-height) - inset) per size so they track density; insets reproduce today's medium/small sizes (pixel-identical default)
Signed-off-by: Siriwat K <siriwatkunaporn@gmail.com>
Expose component color (bg/fg/border) as public CSS vars per variant, color,
and interaction state, layered over the pre-existing --variant-* seam through a
private --_<prop> default. Unset, every token falls back to today's palette
value (Argos zero-diff). Value-states (rest/hover/disabled) carry their token
inside the default; inert states (focus/active) route over it, so a rest/hover
override propagates. MenuItem is single-axis (--MenuItem-<state>-<prop>); its
inert border rides an always-on inset box-shadow ring (no layout shift).

Design: docs/adr/0002 + CONTEXT.md.
color-showcase (Button) and menu-showcase (MenuItem): sidebar presets map
public --Button-*/--MenuItem-* tokens onto a live gallery; Default sets nothing
and is pixel-identical to today.
@code-infra-dashboard

Copy link
Copy Markdown

Deploy preview

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

Bundle size

Bundle Parsed size Gzip size
@mui/material 🔺+17.1KB(+3.26%) 🔺+3.71KB(+2.44%)
@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.

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