You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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 sxcalc — re-deriving every component's internal pixel math by hand, per size (Button padding, OutlinedInput block/inline padding, Checkbox hit area, the Switch's interlocked thumb geometry). There is no single, predictable knob.
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 three built-in, opt-in presets — enhanceHighDensity / enhanceMediumDensity / enhanceLowDensity. You pick a preset, the whole component set reflows off its scale. An unconfigured app renders today's exact pixels (zero Argos diff) and ships no extra CSS variables.
What are the requirements?
Opt-in preset dials. Three preset functions retune density across the component set — no per-component editing, no calc for the consumer. You pick a preset, not tune a scale (mirrors enhanceHighContrast / enhanceColorScheme).
Holistic, off one scale. The whole UI reflows off one shared, per-component-aware scale per preset — not a naive global spacing multiplier (which has no per-component nuance and breaks coupled geometry like the outlined input's floating label).
Non-breaking, zero cost when unused. An unconfigured theme is pixel-identical to today (Argos zero-diff) for every (variant, size) cell, and emits no extra vars.
Predictable coverage. A repeatable internal authoring pattern, so rolling density across components is systematic, not ad hoc.
Spacing + coherent size. Three axes reflow together: spacing (padding/gap/margin/inset) off the shared scale, sizing (width/height/min-height) as raw px, and font-size/line-height + icon fontSize for any component with a size-carrying prop (size, dense, or equivalent). The discriminator is just "does it have a size-carrying prop": if so, everything that already moves with the size step moves with density — text type (Button small≠large, ToggleButton 13/15, MenuItem body1↔body2) and icon glyphs sized by fontSize (Checkbox/Radio icon 24/20, Rating star 18/24/30). Otherwise high gives tiny padding with full-size text/icons — incoherent. Type/icon is fed by each preset's typography config, not the px scale (line-height is unitless); presets may diverge on type independently (in the prototype, high reflows the full type ramp, low adjusts button type only, medium keeps default type). A component with no size-carrying prop keeps today's type/icon frozen.
What are our options?
Public shape. Three opt-in presets — enhanceHighDensity / enhanceMediumDensity / enhanceLowDensity — mirroring the existing enhanceColorScheme / high-contrast helpers. Not a first-class createTheme({ density }) node, so unconfigured apps stay lean, and not a public scale option — each preset's scale (shape/steps, per-component step mappings) is settled internally.
Internal mechanism — emitted theme overrides, zero component-source changes. Each preset computes every covered component's density-bearing values off its scale and emits them as theme styleOverrides against the selectors the component already ships — its size/densevariants, its slots. Theme styleOverrides win over built-in styles, so the targeted (variant, size) reflows; no preset → nothing emitted → today's exact pixels. Component source is completely untouched — no per-property CSS-var chain baked into styled source, no render-time branching. The per-component mapping (which value reflows off which scale step, per built-in (variant, size)) is authored directly inside each preset — no exported map layer — and kept machine-checkable by a generated emission snapshot + parity test. (Earlier drafts explored per-property var chains in component source — two- and three-layer token models — and exported private_* map constants; both were dropped once emission proved it could reproduce the exact pixels with zero source changes.)
Calc-coupled geometry — one private var, decided per component. A flat emitted value can't express a dimension that derives from another across sub-elements. For those, the preset emits one private CSS var (--_*) inside the overrides and derives the coupled values from it with calc, so connected pieces stay aligned at any density — still zero source changes. Current set: the outlined input's floating-label bridge (--_outlinedInputPadBlock → label --_restY/--_shrinkY via :has()), filled/standard input block padding (--_filledInputPadTop/Bottom, --_inputPadTop/Bottom/MarginTop), Autocomplete input padding, Tooltip arrow (--_arrowSize), Chip height (--_height), Switch thumb/track geometry (--_height/--_pad/--_thumbSize), Dialog paper margin (--_dialogMargin → the 100% - margin×2 viewport calcs; media-query guards stay static — vars can't reach @media), PaginationItem height (--_height → pill radius h/2). Which dimensions get a var is decided per component during rollout.
Token granularity. Per-component, not a fixed 4-side schema: start from the broadest shorthand and split a side out only when the implementation derives from it (e.g. OutlinedInput splits block padding because the floating label's offset derives from it). Keeps the surface lean and structure-true.
Slot emission (root vs portal). Each preset writes into components.Mui<Component>.styleOverrides, keyed by slot. Default = root (or a separately-themed sub-component's own root, e.g. MuiMenuItem, which is targeted directly so it works inside a portal). Exception: when a value is consumed by a slot that renders through a portal (Popper/Popover/Modal), it must be emitted on the portal slot, not root. Canonical case: Autocomplete — box/tag values on root, but option/listbox values on the listbox (popper) slot, since the options render outside root. A component's mapping can therefore split across slots ({ root, listbox, … }).
Base inheritance. When a derived component inherits a value from a public base it styled()-extends without overriding it (standard Input ← InputBase input padding), the preset emits on the base's theme key (MuiInputBase.styleOverrides) and the derived inherits it through the normal cascade. Components that override the inherited value (OutlinedInput/FilledInput input padding; the ButtonBase family's padding: 0 reset) get their own emission on their own key.
JS-gated dimensions — theme defaultProps (cross-package, MUI X Data Grid). Some dimensions never reach CSS at all: the Data Grid's row/header heights feed the virtualizer's JS math (row positions, scroll height) and land in the DOM as inline styles — a styleOverrides height would desync virtualization. For those, each preset attaches theme defaultProps instead: rowHeight 28/40/60 (high/medium/low) with columnHeaderHeight on the same bases, spread so an app's own theme defaults win. The grid's own density prop stays unset — its fixed ×0.7/×1/×1.3 multiplier stops cannot express a 1.5× low-density ratio (floor(base × 1.3) never lands on 60) while exact per-preset bases can, and the grid's toolbar density selector keeps working as a relative multiplier on top of the preset base. Everything else in the grid (cell/header insets, toolbar/footer/panel paddings, Pro/Premium chrome sizing) rides the same emitted styleOverrides, and grid chrome built from Material components (column menu, TablePagination, checkboxes, filter fields) reflows through the Material emissions with no grid-specific work.
Proposed solution
Three presets — the sole public density entry:
import{createTheme,enhanceHighDensity,enhanceMediumDensity,enhanceLowDensity,}from'@mui/material/styles';// Pixel-identical to today — no preset applied.consttheme=createTheme({cssVariables: true});// Denser / roomier apps — pick a preset; the whole UI reflows off its scale.consthigh=enhanceHighDensity(createTheme({cssVariables: true}));constmedium=enhanceMediumDensity(createTheme({cssVariables: true}));constlow=enhanceLowDensity(createTheme({cssVariables: true}));
Value
Behavior
no preset
Pixel-identical to today (zero-diff), no extra vars
enhanceHighDensity(theme)
Whole UI reflows denser off the high-density scale
enhanceMediumDensity(theme)
Whole UI on the medium scale
enhanceLowDensity(theme)
Whole UI reflows roomier off the low-density scale
Each preset ships a named --mui-density-* step scale on the theme's own CSS-variables channel (materialized at the theme's rootSelector by ThemeProvider — no CssBaseline requirement) and emits each component's overrides against it. The emitted overrides + per-component mapping are what this RFC signs off, so coverage is predictable. Scale shape/steps and per-component step-mappings are settled internally and out of this RFC's scope.
Scope. Spacing + sizing + (for components with a size-carrying prop) font-size/line-height/icon-fontSize, for each component's built-in sizes. Out of scope: the internal scale design, density as a createTheme node, typographic/icon scaling of components with no size-carrying prop, and custom (theme-added) size density (a custom size falls back to today's pixels; deferred, opt-in follow-up).
Currently covered (prototype #48749).32 Material families verified & surfaced in the playground: Button (incl. IconButton) · ButtonGroup · Menu (MenuItem, List) · Tooltip · TextField (OutlinedInput, FilledInput, standard Input, InputBase, InputLabel, InputAdornment) · Select · Autocomplete · Tabs · Accordion · Checkbox · Radio · Switch (calc-coupled thumb/track geometry) · SvgIcon · Chip · Alert · Card · Avatar · Badge · ToggleButton · Breadcrumbs · Stepper · LinearProgress · Table (TableCell, TableSortLabel, TablePagination) · Slider · Divider · Toolbar (responsive regular/dense bar heights) · Dialog (title/content/actions + paper margin with viewport-calc coupling) · Fab · Pagination · ListItemButton · SnackbarContent · BottomNavigation. Cross-package: MUI X Data Grid — heights via theme defaultProps (row 28/40/60, header on the same bases; grid density prop unset), insets/toolbar/footer/panels/menu/quick-filter/overlays and Pro/Premium chrome (header filters, grouping indent, pivot/AI/charts panels, sidebar) via emitted styleOverrides. MUI X Tree View — item indentation via defaultProps.itemChildrenIndentation carrying a density-var string (the indentation var is set as an inline style on the tree root, so styleOverrides can't reach it; the string prop passes through verbatim — the prop value stays CSS), row height via the component's own --TreeView-itemHeight hook (unset upstream → the no-preset state stays pixel-identical), content paddings/gap via steps with the depth-indent calc re-emitted on a step base. MUI X Date/Time Pickers — day geometry via one private --_daySize var on the day-calendar root (re-points --PickerDay-size, drives weekday/week-number boxes + the 6-week container calcs whose master values are build-time JS constants), root box/buttons/clock-section widths raw per preset, header/toolbar/clock spacing via steps. All prototyped from the Material presets as untyped keys — the production home (Material-side vs an X-side enhancer consuming the same scale) is an open packaging decision. Remaining candidates (no emissions yet): Rating, the List content family (ListItem, ListItemText, ListSubheader), form wrappers (FormControl, FormHelperText, FormLabel), AvatarGroup, ToggleButtonGroup, SpeedDial, MobileStepper — plus prop-driven sizing (CircularProgress size, ImageList gap) now expressible through the defaultProps channel.
Three enhance*Density presets emitting theme styleOverrides — zero component-source changes; 32 Material families + MUI X Data Grid, Tree View, and Date/Time Pickers verified & surfaced
Slot-aware emission — overrides on root by default, on the portal slot for portaled content (Autocomplete listbox, Tooltip tooltip)
font-size/line-height + icon-fontSize reflow for size-carrying (size/dense) components — from the preset's typography config, not the px scale
:has() label bridge so OutlinedInput + floating InputLabel move together, centered at any density (private --_* var emitted inside the overrides)
Interactive playground — flip presets, inspect/override every emitted value, visual-debug overlays, export a standalone density.ts
Local zero-diff harness (density:shot) asserting the unconfigured render is pixel-identical to master
MUI X Data Grid coverage — heights via theme defaultProps (28/40/60; grid density prop unset), panels/pro/premium chrome via styleOverrides, playground family with grid demos
MUI X Tree View + Date/Time Pickers coverage — indentation via a density-var string defaultProps (inline-style var seam), day geometry via a private day-size var re-pointing the component's own var, playground families with tree/calendar/clock demos
Switch interlocked geometry derived with calc (thumb centered under density scaling)
Installable build via pkg.pr.new + upstream Argos zero-diff across the combined set
Docs page customization/density refresh
Decision need
Function shape — enhanceDensity(theme, { scale }) → decided: three presetsenhanceHighDensity / enhanceMediumDensity / enhanceLowDensity (you pick a preset, not tune a scale; mirrors enhanceHighContrast / enhanceColorScheme).
Internal-only mapping — decided: the per-component mapping is inlined in the presets (no exported map constants, no public per-component var surface); the calc-coupling --_* vars are internal and undocumented. The presets are the sole public surface.
Per-side var naming (internal convention, shared with the upcoming state-color-tokens work) — decided: property-mirror camelCase, component-prefixed (--_filledInputPadTop/--_filledInputPadBottom). Block sides use Top/Bottom (never flip under RTL); an inline-axis single side, when first needed, uses the logical form (PadInlineStart/PadInlineEnd), never physical left/right.
Known constraint (not a question): the outlined-input label bridge uses :has() (Chrome 105 / Safari 15.4 / Firefox 121) — confirm the support baseline at ship time.
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 (Button padding, OutlinedInput block/inline padding, Checkbox hit area, the Switch's interlocked thumb geometry). There is no single, predictable knob.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 three built-in, opt-in presets —
enhanceHighDensity/enhanceMediumDensity/enhanceLowDensity. You pick a preset, the whole component set reflows off its scale. An unconfigured app renders today's exact pixels (zero Argos diff) and ships no extra CSS variables.What are the requirements?
calcfor the consumer. You pick a preset, not tune a scale (mirrorsenhanceHighContrast/enhanceColorScheme).(variant, size)cell, and emits no extra vars.fontSizefor any component with a size-carrying prop (size,dense, or equivalent). The discriminator is just "does it have a size-carrying prop": if so, everything that already moves with the size step moves with density — text type (Buttonsmall≠large, ToggleButton13/15, MenuItembody1↔body2) and icon glyphs sized byfontSize(Checkbox/Radio icon24/20, Rating star18/24/30). Otherwisehighgives tiny padding with full-size text/icons — incoherent. Type/icon is fed by each preset's typography config, not the px scale (line-height is unitless); presets may diverge on type independently (in the prototype,highreflows the full type ramp,lowadjusts button type only,mediumkeeps default type). A component with no size-carrying prop keeps today's type/icon frozen.What are our options?
Public shape. Three opt-in presets —
enhanceHighDensity/enhanceMediumDensity/enhanceLowDensity— mirroring the existingenhanceColorScheme/ high-contrast helpers. Not a first-classcreateTheme({ density })node, so unconfigured apps stay lean, and not a public scale option — each preset's scale (shape/steps, per-component step mappings) is settled internally.Internal mechanism — emitted theme overrides, zero component-source changes. Each preset computes every covered component's density-bearing values off its scale and emits them as theme
styleOverridesagainst the selectors the component already ships — itssize/densevariants, its slots. ThemestyleOverrideswin over built-in styles, so the targeted(variant, size)reflows; no preset → nothing emitted → today's exact pixels. Component source is completely untouched — no per-property CSS-var chain baked into styled source, no render-time branching. The per-component mapping (which value reflows off which scale step, per built-in(variant, size)) is authored directly inside each preset — no exported map layer — and kept machine-checkable by a generated emission snapshot + parity test. (Earlier drafts explored per-property var chains in component source — two- and three-layer token models — and exportedprivate_*map constants; both were dropped once emission proved it could reproduce the exact pixels with zero source changes.)Calc-coupled geometry — one private var, decided per component. A flat emitted value can't express a dimension that derives from another across sub-elements. For those, the preset emits one private CSS var (
--_*) inside the overrides and derives the coupled values from it withcalc, so connected pieces stay aligned at any density — still zero source changes. Current set: the outlined input's floating-label bridge (--_outlinedInputPadBlock→ label--_restY/--_shrinkYvia:has()), filled/standard input block padding (--_filledInputPadTop/Bottom,--_inputPadTop/Bottom/MarginTop), Autocomplete input padding, Tooltip arrow (--_arrowSize), Chip height (--_height), Switch thumb/track geometry (--_height/--_pad/--_thumbSize), Dialog paper margin (--_dialogMargin→ the100% - margin×2viewport calcs; media-query guards stay static — vars can't reach@media), PaginationItem height (--_height→ pill radius h/2). Which dimensions get a var is decided per component during rollout.Token granularity. Per-component, not a fixed 4-side schema: start from the broadest shorthand and split a side out only when the implementation derives from it (e.g. OutlinedInput splits block padding because the floating label's offset derives from it). Keeps the surface lean and structure-true.
Slot emission (root vs portal). Each preset writes into
components.Mui<Component>.styleOverrides, keyed by slot. Default =root(or a separately-themed sub-component's own root, e.g.MuiMenuItem, which is targeted directly so it works inside a portal). Exception: when a value is consumed by a slot that renders through a portal (Popper/Popover/Modal), it must be emitted on the portal slot, notroot. Canonical case: Autocomplete — box/tag values onroot, butoption/listboxvalues on thelistbox(popper) slot, since the options render outsideroot. A component's mapping can therefore split across slots ({ root, listbox, … }).Base inheritance. When a derived component inherits a value from a public base it
styled()-extends without overriding it (standardInput←InputBaseinput padding), the preset emits on the base's theme key (MuiInputBase.styleOverrides) and the derived inherits it through the normal cascade. Components that override the inherited value (OutlinedInput/FilledInput input padding; the ButtonBase family'spadding: 0reset) get their own emission on their own key.JS-gated dimensions — theme
defaultProps(cross-package, MUI X Data Grid). Some dimensions never reach CSS at all: the Data Grid's row/header heights feed the virtualizer's JS math (row positions, scroll height) and land in the DOM as inline styles — astyleOverridesheight would desync virtualization. For those, each preset attaches themedefaultPropsinstead:rowHeight28/40/60 (high/medium/low) withcolumnHeaderHeighton the same bases, spread so an app's own theme defaults win. The grid's owndensityprop stays unset — its fixed ×0.7/×1/×1.3 multiplier stops cannot express a 1.5× low-density ratio (floor(base × 1.3)never lands on 60) while exact per-preset bases can, and the grid's toolbar density selector keeps working as a relative multiplier on top of the preset base. Everything else in the grid (cell/header insets, toolbar/footer/panel paddings, Pro/Premium chrome sizing) rides the same emittedstyleOverrides, and grid chrome built from Material components (column menu,TablePagination, checkboxes, filter fields) reflows through the Material emissions with no grid-specific work.Proposed solution
Three presets — the sole public density entry:
enhanceHighDensity(theme)enhanceMediumDensity(theme)enhanceLowDensity(theme)Each preset ships a named
--mui-density-*step scale on the theme's own CSS-variables channel (materialized at the theme'srootSelectorbyThemeProvider— noCssBaselinerequirement) and emits each component's overrides against it. The emitted overrides + per-component mapping are what this RFC signs off, so coverage is predictable. Scale shape/steps and per-component step-mappings are settled internally and out of this RFC's scope.Scope. Spacing + sizing + (for components with a size-carrying prop) font-size/line-height/icon-
fontSize, for each component's built-in sizes. Out of scope: the internal scale design,densityas acreateThemenode, typographic/icon scaling of components with no size-carrying prop, and custom (theme-added)sizedensity (a custom size falls back to today's pixels; deferred, opt-in follow-up).Currently covered (prototype #48749). 32 Material families verified & surfaced in the playground: Button (incl. IconButton) · ButtonGroup · Menu (MenuItem, List) · Tooltip · TextField (OutlinedInput, FilledInput, standard Input, InputBase, InputLabel, InputAdornment) · Select · Autocomplete · Tabs · Accordion · Checkbox · Radio · Switch (calc-coupled thumb/track geometry) · SvgIcon · Chip · Alert · Card · Avatar · Badge · ToggleButton · Breadcrumbs · Stepper · LinearProgress · Table (TableCell, TableSortLabel, TablePagination) · Slider · Divider · Toolbar (responsive regular/dense bar heights) · Dialog (title/content/actions + paper margin with viewport-calc coupling) · Fab · Pagination · ListItemButton · SnackbarContent · BottomNavigation. Cross-package: MUI X Data Grid — heights via theme
defaultProps(row 28/40/60, header on the same bases; griddensityprop unset), insets/toolbar/footer/panels/menu/quick-filter/overlays and Pro/Premium chrome (header filters, grouping indent, pivot/AI/charts panels, sidebar) via emittedstyleOverrides. MUI X Tree View — item indentation viadefaultProps.itemChildrenIndentationcarrying a density-var string (the indentation var is set as an inline style on the tree root, sostyleOverridescan't reach it; the string prop passes through verbatim — the prop value stays CSS), row height via the component's own--TreeView-itemHeighthook (unset upstream → the no-preset state stays pixel-identical), content paddings/gap via steps with the depth-indent calc re-emitted on a step base. MUI X Date/Time Pickers — day geometry via one private--_daySizevar on the day-calendar root (re-points--PickerDay-size, drives weekday/week-number boxes + the 6-week container calcs whose master values are build-time JS constants), root box/buttons/clock-section widths raw per preset, header/toolbar/clock spacing via steps. All prototyped from the Material presets as untyped keys — the production home (Material-side vs an X-side enhancer consuming the same scale) is an open packaging decision. Remaining candidates (no emissions yet): Rating, the List content family (ListItem, ListItemText, ListSubheader), form wrappers (FormControl, FormHelperText, FormLabel), AvatarGroup, ToggleButtonGroup, SpeedDial, MobileStepper — plus prop-driven sizing (CircularProgresssize, ImageListgap) now expressible through thedefaultPropschannel.Prototype — TODOs
Prototype PR: #48749 · live playground: https://deploy-preview-48749--material-ui.netlify.app/experiments/density-playground/
enhance*Densitypresets emitting themestyleOverrides— zero component-source changes; 32 Material families + MUI X Data Grid, Tree View, and Date/Time Pickers verified & surfacedrootby default, on the portal slot for portaled content (Autocompletelistbox, Tooltiptooltip)fontSizereflow for size-carrying (size/dense) components — from the preset's typography config, not the px scale:has()label bridge soOutlinedInput+ floatingInputLabelmove together, centered at any density (private--_*var emitted inside the overrides)density.tsdensity:shot) asserting the unconfigured render is pixel-identical tomasterdefaultProps(28/40/60; griddensityprop unset), panels/pro/premium chrome viastyleOverrides, playground family with grid demosdefaultProps(inline-style var seam), day geometry via a private day-size var re-pointing the component's own var, playground families with tree/calendar/clock demosSwitchinterlocked geometry derived withcalc(thumb centered under density scaling)customization/densityrefreshDecision need
→ decided: three presetsenhanceDensity(theme, { scale })enhanceHighDensity/enhanceMediumDensity/enhanceLowDensity(you pick a preset, not tune a scale; mirrorsenhanceHighContrast/enhanceColorScheme).--_*vars are internal and undocumented. The presets are the sole public surface.--_filledInputPadTop/--_filledInputPadBottom). Block sides useTop/Bottom(never flip under RTL); an inline-axis single side, when first needed, uses the logical form (PadInlineStart/PadInlineEnd), never physical left/right.Known constraint (not a question): the outlined-input label bridge uses
:has()(Chrome 105 / Safari 15.4 / Firefox 121) — confirm the support baseline at ship time.Resources and benchmarks
docs/adr/0001-css-var-density-adapter.md,docs/adr/density-adapter-rollout.mdSearch keywords: