[RFC] enhanceDensity — normalize components into a consistent scale
Nobody has claimed this yet.
- 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
defaultProps—size: 'small'on Button,dense: trueon 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?
- 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. - Theme configurable. Density is set at the theme level — no per-component editing, no
calcfor the consumer — and rides the existing theme tokens (theme.spacing,shape.borderRadius,theme.typography) instead of minting parallel ones. - 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 |
|---|---|---|
xxSmall … xxLarge |
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() sxtakes 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
styleOverridesagainst 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 renders —
rootby 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*.styleOverridesand (for JS-gated dimensions)components.Mui*.defaultProps. Nothing else on the theme object is touched — e.g.theme.mixins.toolbarkeeps 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:
- plain:
xsmallxxsmallxlargeetc - hyphen:
x-smallxx-smallx-largeetc - camel:
xSmallxxSmallxLargeetc — 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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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