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: 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?
- 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
calc for 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 touch-target value, icon size via icon-target, 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 |
xx-small … xx-large |
4 / 8 / 12 / 16 / 24 / 32 / 48 px |
spacing steps |
touch-target |
32px |
sizing constant |
icon-target |
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 emit as plain lengths, get no CSS variable, and theme.spacing() does not resolve them. Per-size ramps derive from the constants — small = calc(touch-target − x-small), large = calc(touch-target + small) — 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.
How it ships
- the scale append to
theme.spacing()
sx takes the same names — <Box sx={{ p: 'small', gap: 'x-small' }} />
- 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 renders —
root 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.
Need Decisions
Search keywords: density, compact, comfortable, spacing scale, touch target
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:
defaultProps—size: 'small'on Button,dense: trueon ListItem,variant: 'dense'on Toolbar,margin: 'dense'on inputs — a different prop per component.Beyond that page, the only levers are
styleOverridesandsxcalc— 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?
(variant, size)cell, and emits no extra vars.calcfor the consumer — and rides the existing theme tokens (theme.spacing,shape.borderRadius,theme.typography) instead of minting parallel ones.Proposed solution
One public entry.
enhanceDensityre-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 dedicatedtouch-targetvalue, icon size viaicon-target, so the misaligned defaults above line up by construction.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
xx-small…xx-largetouch-targeticon-targetThe seven steps space things: they ship as
--mui-spacing-*CSS variables andtheme.spacing('small')resolves them. The two constants size things: they emit as plain lengths, get no CSS variable, andtheme.spacing()does not resolve them. Per-size ramps derive from the constants —small = calc(touch-target − x-small),large = calc(touch-target + small)— 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.How it ships
theme.spacing()sxtakes the same names —<Box sx={{ p: 'small', gap: 'x-small' }} />styleOverridesagainst selectors the components already ship. Nothing applied → nothing emitted → today's exact pixels.--_*) are used for components with multiple slots that needs to be in synced without breaking apart, e.g. Inputs, Autocomplete, Switchrootby default, the portal slot when the styled element renders through a portal.Need Decisions
sx={{ p: <show scale> }}. I lean toward skipping this as it requires changes on MUI System on the type levelSearch keywords: density, compact, comfortable, spacing scale, touch target