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

@siriwatknp siriwatknp commented Jun 5, 2026

Copy link
Copy Markdown
Member

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

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

The idea

Let designers tune component density — per component, per size, or across the
whole app — without editing component source or writing calc.

Every density-bearing dimension (padding, gap, height…) becomes a plain CSS
variable with a literal-px fallback, so an un-configured theme renders today's
exact pixels
(Argos zero-diff). You opt in only where you want a denser or
roomier UI; nothing else changes.

Consumer usage

Opt into holistic density with one function — enhanceDensity:

import { createTheme, enhanceDensity } from '@mui/material/styles';

// Defaults derive from theme.spacing → pixel-identical to today.
const theme = enhanceDensity(createTheme({ cssVariables: true }));

Make the app denser (or roomier) by tuning the 7-step scale:

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

enhanceDensity emits the scale as --mui-density-* vars (needs <CssBaseline />)
and wires every supported component to it. Change the numbers → the whole UI reflows.

Just one component? Scope its public token — no function required:

// every small Button inside is denser (custom props inherit)
<Box sx={{ '--Button-small-pad': '2px 8px' }}></Box>

Playground

image

/experiments/density-showcase
the left sidebar toggles three presets (Compact / Normal / Comfort); the main
panel is a live gallery of every supported component.

Things to try:

  • Flip a preset — the entire gallery reflows off the one scale. Normal is
    pixel-identical to today.
  • Read the scale panel — the resolved --mui-density-* values for the active preset.
  • Expand a component in the token panel — see exactly which sized tokens it
    pulls and what each resolves to.

Per-component token reference: /experiments/density-tokens.

Technical 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:

// 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 their layer untouched: an agnostic root with no design
opinion, the Material sizes/variants on top, and a design-system override surface
through the public tokens.

A few shapes the model handles:

  • Sized vs. base vs. state tokens. Axes are sized by default. 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 tokenizes its real dims and derives the coupled values with
    calc, so the thumb stays centered at any density.

Supported today: 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).

Design notes:

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

What's next

Still to settle before this could ship:

  • Define the density token set per component. Which dimensions are
    density-bearing has been picked per component during rollout — we need an agreed,
    documented list of tokens for each component (and a rule for what deliberately
    gets none), so the surface is predictable instead of ad hoc.
  • Edge cases.
    • Asymmetric padding — FilledInput and standard Input have uneven block
      padding (4/5, 25/8); a single padBlock won't cover it, they need a
      per-side seam.
    • :has() baseline — the outlined-label bridge relies on :has()
      (Chrome 105 / Safari 15.4 / Firefox 121); confirm the support target.
    • Vars outside theme.varsenhanceDensity emits --mui-density-*
      post-createTheme, outside the standard css-var pipeline (see ADR-0001).
      Decide whether density should be a first-class createTheme node so it
      re-scopes at any level.
  • Validate the default step mappings. The per-component scale-step mappings in
    enhanceDensity (e.g. Switch dims composed from steps) are hand-tuned to land on
    today's pixels — confirm they hold across the full set.

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

code-infra-dashboard Bot commented Jun 5, 2026

Copy link
Copy Markdown

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