Skip to content

[RFC] enhanceDensity — normalize components into a consistent scale #48746

Description

@siriwatknp

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 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-smallxx-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 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.

Need Decisions

  • 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
  • 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions