mui / mui/material-ui

[RFC] enhanceDensity — normalize components into a consistent scale

Open
#48,746 4 comments 1 reaction 0 assignees View on GitHub

Nobody has claimed this yet.

package: material-ui RFC
Dominant language
JavaScript
Stars
99.1k
Forks
32.5k
Avg merge
2d 17h
Merged PRs (30d)
106

Description

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

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with the RFC's Proposed solution, scale, and Need Decisions sections, then locate the theme enhancer and component override entry points in the repository; the issue names no files or tests. Done would require resolving the open Sx typings and extensibility questions and implementing the opt-in density system while preserving the stated default behavior.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript, react
Domain
design, frontend
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Needs clarification
Newbie friendliness
30/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.