#48746·material-ui

[RFC] enhanceDensity — normalize components into a consistent scale

Author: siriwatknpCreated Jun 30, 2026Updated Sep 16, 2026
LabelsRFCpackage: material-ui

What's the problem?

Material UI components ship the comfortable Material Design spacing. Teams building data-dense, professional UIs (dashboards, admin consoles, design tools) routinely need denser layouts — and some need roomier ones.

There's an existing Density page, but it's a per-component workaround, not a density system:

  • No single mechanism. It's a grab-bag of defaultPropssize: 'small' on Button, dense: true on ListItem, variant: 'dense' on Toolbar, margin: 'dense' on inputs — a different prop per component.
  • Only 13 components. Button, Fab, FilledInput, FormControl, FormHelperText, IconButton, InputBase, InputLabel, ListItem, OutlinedInput, Table, TextField, Toolbar — nothing else.
  • One step, one direction. A single "denser" toggle; no tunable scale, no roomier direction.
  • Not holistic — by its own warning. The page states "you should not apply this theme to your whole application," so it can't be the app-wide density dial teams actually want.

Beyond that page, the only levers are styleOverrides and sx calc — re-deriving every component's internal pixel math by hand, per size. There is no single, predictable knob.

Even at the defaults, control sizes don't line up. A "medium" form row mixes a 36.5px Button, a 56px outlined TextField, a 40px IconButton and a 42px Checkbox hit area; the same control shifts height across variants (a standard-variant input box is ~32px vs 56px outlined/filled). The defaults are per-component pixel math, not a shared scale — nothing a theme can turn aligns them.

Material 3 itself documents spacing as a deliberate, adjustable system — compact vs. comfortable off one consistent scale (Applying spacing). A Material UI app can't express that today without forking component source.

This RFC proposes one built-in, opt-in enhancer — enhanceDensity(theme, scale?). The library ships one canonical scale; denser and roomier are userland recipes. An unconfigured app renders today's exact pixels (Argos zero-diff) and ships no extra CSS variables.

What are the requirements?

  1. Non-breaking, minimal cost when unused. An unconfigured theme is pixel-identical to today (Argos zero-diff) for every (variant, size) cell, and emits no extra vars.
  2. Theme configurable. Density is set at the theme level — no per-component editing, no calc for the consumer — and rides the existing theme tokens (theme.spacing, shape.borderRadius, theme.typography) instead of minting parallel ones.
  3. Coherent size. Spacing, sizing and (for components with a size-carrying prop) font/icon size reflow together — denser padding never pairs with full-size text — and control heights align across components.

Proposed solution

One public entry. enhanceDensity re-authors the whole component library onto one shared scale so sizing stays consistent across components. This is not proportional shrinking: each component maps its own dimensions to the scale's steps, and anchor controls converge on a dedicated touchTarget value, icon size via iconSize, so the misaligned defaults above line up by construction.

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

// Pixel-identical to today — nothing applied.
const theme = createTheme({ cssVariables: true });

// enhancer standardize every components with a dedicated scale
const enhanced = enhanceDensity(createTheme({ cssVariables: true }));

There are deliberately no built-in density modes. An earlier draft of this RFC proposed a 'high' | 'medium' | 'low' mode argument. That was dropped: the modes shared all their code, and a mode enum forces the library to own three sets of design values that properly belong to a product. Denser and roomier ship as documented copy-paste recipes instead.

The scale

Key Default Kind
xxSmallxxLarge 4 / 8 / 12 / 16 / 24 / 32 / 48 px spacing steps
touchTarget 32px sizing constant
iconSize 16px sizing constant

The seven steps space things: they ship as --mui-spacing-* CSS variables and theme.spacing('small') resolves them. The two constants size things: they ship as --mui-touchTarget / --mui-iconSize — their own namespace, deliberately not under --mui-spacing-* — and because they are not spacing keys, theme.spacing() does not resolve them. The override object mirrors that split: the seven steps nest under spacing, the two constants sit beside it.

enhanceDensity(theme, {
  spacing: { small: 10 },
  touchTarget: 28,
});

Per-size ramps derive from the constants — small = calc(touchTarget − $token), large = calc(touchTarget + $token) — so moving one constant carries all three sizes rather than only the middle one.

The scale is closed. The override object is strictly typed; a misspelled key is a compile error, and new step names cannot be added. Registering user-defined steps was spiked and declined — the steps line components up precisely because there are few of them. For a value the ladder doesn't carry, apps use a multiple of the spacing unit, which theme.spacing() has always accepted.

Use the scale

The scale reads back off the theme, so app-level customization sits on the same ladder the components reflow on — no second set of numbers to keep in sync.

// Spacing steps — in any CSS property, a `styleOverrides` callback, or `styled`.
theme.spacing('medium'); // '16px' (static) / 'var(--mui-spacing-medium, …)' (cssVariables)
theme.spacing('-xSmall'); // a leading dash negates

<Box sx={{ p: 'small', gap: 'xSmall' }} />;

// Sizing constants — not spacing keys, so read them off the theme instead.
const Control = styled('div')(({ theme }) => ({
  height: (theme.vars || theme).touchTarget,
  '& svg': { fontSize: (theme.vars || theme).iconSize },
}));

theme.vars carries the variable reference, so the value keeps following a --mui-touchTarget override scoped to a region; the plain theme carries the resolved length. The theme.vars || guard is what makes one customization work on both theme shapes. Both constants are undefined until enhanceDensity has run — density is opt-in, and the types say so.

How it ships

  • the scale append to theme.spacing()
  • sx takes the same names<Box sx={{ p: 'small', gap: 'xSmall' }} />
  • Emitted theme overrides — zero component-source changes. The enhancer computes each component's values off the scale and emits them as styleOverrides against selectors the components already ship. Nothing applied → nothing emitted → today's exact pixels.
  • Private CSS variables (--_*) are used for components with multiple slots that needs to be in synced without breaking apart, e.g. Inputs, Autocomplete, Switch
  • Overrides land where the element rendersroot by default, the portal slot when the styled element renders through a portal.
  • Gaps replace sibling margins wherever the layout allows, so there's one value to override instead of a margin pair.
  • Only two theme channels are written: components.Mui*.styleOverrides and (for JS-gated dimensions) components.Mui*.defaultProps. Nothing else on the theme object is touched — e.g. theme.mixins.toolbar keeps its stock values even though the enhanced Toolbar renders shorter; apps pairing density with the mixin spacer pattern should offset off the toolbar itself.

Need Decisions

Scale naming — decided: camelCase

Keeps existing size naming small medium large, using x* as tails for expanding the scale. Options considered:

  1. plain: xsmall xxsmall xlarge etc
  2. hyphen: x-small xx-small x-large etc
  3. camel: xSmall xxSmall xLarge etc — decided

Camel gets the same quote-free object as plain (enhanceDensity(…, { spacing: { xxSmall: 2 }, touchTarget: 24 })) while keeping the xx tails readable, and it matches how the theme already names multi-word keys in CSS variables (--mui-shape-borderRadius, --mui-opacity-inputPlaceholder) — so the steps emit as --mui-spacing-xxSmall. The two sizing constants follow the same rule: touchTarget, iconSize.

Sx typings

  • should sx handle scale autocompletion? when type sx={{ p: <show scale> }}. I lean toward skipping this as it requires changes on MUI System on the type level

Extensibility

  • should the scale be extensible? meaning user can add more keys to the scale. I lean toward skipping this too.

Search keywords: density, compact, comfortable, spacing scale, touch target