Skip to content

[material-ui] CSS-var density adapter experiment#48624

Draft
siriwatknp wants to merge 25 commits into
mui:masterfrom
siriwatknp:exp/css-var-density-adapter
Draft

[material-ui] CSS-var density adapter experiment#48624
siriwatknp wants to merge 25 commits into
mui:masterfrom
siriwatknp:exp/css-var-density-adapter

Conversation

@siriwatknp
Copy link
Copy Markdown
Member

@siriwatknp siriwatknp commented Jun 5, 2026

Preview: https://deploy-preview-48624--material-ui.netlify.app/experiments/density-showcase/

The idea

Let designers tune component density — per component, per size, or holistically across the app — without editing component source, writing calc, or accepting that everything reflows off one global --mui-spacing dial.

This is a third take on density (sibling of feat/components-theme-spacing and poc/css-vars-map): a CSS-variable adapter. Every density-bearing dimension becomes one hand-authorable CSS variable, with a literal-px fallback so the un-configured theme renders today's exact pixels (Argos zero-diff). Nothing breaks; you opt in only where you want to.

The approach

Each component is read as three layers of responsibility, sharing one cascade:

--Component-<size>-<key>   public sized token   → the designer knob (what enhanceDensity targets)
--Component-<key>          agnostic seam        → the styled root's single consumption point
--_<key>                   internal default     → today's Material literal (lives in `variants`)

The styled root consumes the seam, which falls back to the internal default — one consumption point per property, no JS branching on size/variant in the styles:

// agnostic root
padding: 'var(--Button-pad, var(--_pad))',

// Material layer routes the public sized token over the default, per size (in `variants`)
{ props: { size: 'small' }, style: { '--Button-pad': 'var(--Button-small-pad, var(--_pad))' } },

So three audiences each get the layer they care about: an agnostic root with no design opinion, the Material sizes/variants on top, and a design-system override surface through the public tokens — all without touching the others.

Holistic density

One opt-in function (mirroring enhanceHighContrast) turns the per-component tokens into an app-wide density scale. It both emits a --mui-density-* scale and maps each component's sized tokens onto it. createTheme is left untouched:

const theme = enhanceDensity(createTheme({ cssVariables: true }), {
  scale: { xs: '4px', sm: '6px', md: '8px', lg: '12px', xl: '18px', xxl: '24px' /* … */ },
});

The showcase wires this to three presets — Compact / Normal / Comfort — where flipping one switch reflows the whole component gallery. Normal is pixel-identical to today.

A few shapes the model handles

  • Sized vs. base vs. state tokens. Axes are sized by default (per-size tunable). A boolean dense prop uses a state token (only the on-state is named). A size-invariant base token is reserved for the rare axis where per-size override is meaningless.
  • Paired siblings. A TextField's floating InputLabel is a preceding sibling of the input; OutlinedInput owns a :has bridge so one padding knob moves the input box and its label together.
  • Shared base + interlocked geometry. Checkbox/Radio/Switch share SwitchBase's seam. A Switch's width/height/thumb/touch/travel all move together, so it tokenizes the real dims and derives the coupled values with calc — the thumb stays centered at any density.

Scope

Button · OutlinedInput (+ InputLabel, TextField outlined) · the dashboard set (Chip, IconButton, MenuItem, ListItem/Button/Icon/Text, ListSubheader, Toolbar, Tab/Tabs, TablePagination, CardContent, Select, Breadcrumbs, InputAdornment, Badge) · the SwitchBase family (Checkbox, Radio, Switch).

Try it

  • Showcase (preset switcher + live token readout): /experiments/density-showcase
  • Per-component tokens: /experiments/density-tokens

Design notes

  • Decision record: docs/adr/0001-css-var-density-adapter.md
  • Rollout recipes + gotchas: docs/adr/density-adapter-rollout.md

Not intended to merge as-is — opened for the deploy preview and review.

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.
@code-infra-dashboard
Copy link
Copy Markdown

code-infra-dashboard Bot commented Jun 5, 2026

Deploy preview

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

Bundle size

Bundle Parsed size Gzip size
@mui/material 🔺+16.4KB(+3.15%) 🔺+3.15KB(+2.10%)
@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.

… + --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
@siriwatknp siriwatknp marked this pull request as draft June 7, 2026 06:41
siriwatknp added 16 commits June 7, 2026 16:35
…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.
@github-actions github-actions Bot added the PR: out-of-date The pull request has merge conflicts and can't be merged. label Jun 8, 2026
…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)
@siriwatknp siriwatknp changed the title [material-ui] CSS-var density adapter experiment (Button) [material-ui] CSS-var density adapter experiment Jun 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

PR: out-of-date The pull request has merge conflicts and can't be merged.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant