Skip to content

[material-ui] Spacing-derived component dimensions#48585

Closed
siriwatknp wants to merge 29 commits into
mui:masterfrom
siriwatknp:feat/components-theme-spacing
Closed

[material-ui] Spacing-derived component dimensions#48585
siriwatknp wants to merge 29 commits into
mui:masterfrom
siriwatknp:feat/components-theme-spacing

Conversation

@siriwatknp

Copy link
Copy Markdown
Member

Summary

Rolls a --mui-spacing-driven density system across packages/mui-material/src/*. Every padding / margin / gap (and selected geometry on Chip) that should scale with theme spacing now rides --mui-spacing via calc(${theme.spacing(N)} ± offset), so users can shift the entire app's density by changing one CSS variable — pixel-identical to today at the default 8px.

Decision + spec:

  • docs/adr/0001-spacing-derived-component-dimensions.md — decision + rationale
  • docs/adr/spacing-derived-dimensions-spec.md — the rule, worked examples, "choosing N" principle, clamp pattern
  • docs/adr/spacing-derived-rollout-plan.md — per-component checklist (iter 1 done + iter 2 in progress) and the iter-2 backlog

Scope (≈ 50 components derived; rest audited-skip with rationale):

  • Input/form family — Button, OutlinedInput, FilledInput, InputBase, Input, all three InputLabel transform sets, InputAdornment, FormControl, FormControlLabel
  • Buttons/controls — IconButton, ToggleButton, Tab, BottomNavigationAction, SpeedDialAction, PaginationItem, Fab extended
  • Lists/menus — List, ListItem, ListItemButton, ListItemAvatar, ListItemIcon, ListItemText, ListSubheader, MenuItem, Autocomplete
  • Surfaces — Accordion family, Alert, Dialog content parts, Card family, SnackbarContent, Tooltip, Breadcrumbs, MobileStepper
  • Steppers/tables/misc — Stepper family, TableCell, TablePagination, TableSortLabel, ImageListItemBar, Badge
  • Iter-2 refinements — Chip (coupled formula, N=2 container + N=1 inner glyphs, max-clamped negatives), FormControlLabel -11, List gutter 16, SwitchBase 9

Key principles (full detail in the spec):

  • Pixel-identical at default by construction: calc(spacing(N) ± X) ≡ literal at --mui-spacing: 8px. Verified per component with a Playwright harness (scripts/spacing-screenshots/, maxDiffPixels: 0).
  • Vertical derives, anchored horizontal stays literal. Notch, label-x, icon reservation, checkbox columns, etc.
  • N is the step coefficient. Larger containers N=2, smaller inner glyphs N=1, so containers respond visibly while contents scale gently and don't outgrow their parent.
  • Static styled objects wrapped in memoTheme(({ theme }) => …). Compensation negatives use theme.spacing(-n) ± offset; inner-positive offsets that must not go negative wrap in max(…, 0px).

Iter-2 backlog (deferred — see plan § "Iteration 2"): coordinated horizontal pass (input inline, list inset, autocomplete reservation, checkbox column), geometry→spacing for Switch/Slider/Fab/PaginationItem, Dialog breakpoint coupling, AvatarGroup default.

Test plan

  • Per-component screenshot harness — pixel-identical at default + density review (6/8/10px) green on every derived component
  • Unit tests green — browser (chromium) + node (jsdom). One Tab computed-style test marked skipIf(isJsdom()) for the calc value
  • eslint + prettier clean across all changed files
  • Argos CI — anticipate small AA diff on a few components (calc-vs-literal sub-pixel; documented in spec § "Workflow refinement")
  • Manual density review in the docs experiment fixture (docs/pages/experiments/spacing-fixture.tsx)

siriwatknp added 29 commits May 27, 2026 17:10
Route Button padding (all variants/sizes) and OutlinedInput block padding +
outlined InputLabel resting position through theme.spacing() so they ride
--mui-spacing for density. Pixel-identical at default theme. Horizontal axis
stays literal on notch-coupled components to avoid breaking the outline.

Adds ADR 0001 + replication spec for the remaining components.
Rollout plan checklist (~70 components, grouped) with per-component
requirement, edge cases, and a local Playwright verification flow (no Argos):
toHaveScreenshot asserts the default is pixel-identical before/after; 6px/10px
shots saved for density review. Adds spacing-fixture route + spacing:shot
scripts.
FilledInput block padding (incl. 25/8 label-space, hiddenLabel, small,
multiline) and the filled InputLabel transformY ride theme.spacing();
horizontal (inline 12, label x) stays literal. Pixel-identical at default.
Harness writes to its own outputDir (stop clobbering tracked test-results/).
Document FilledInput learnings: horizontal coupling extends to filled/standard
label x; shrunk-label tracking splits by variant (border=literal, padding=tracks);
verify floating labels with a valued field.
Block padding only (4px/5px -> spacing(1)-4px / -3px); 1px small nudge stays
literal (sub-unit); inline already 0. Standard InputLabel tracking is a
separate rollout item.
marginTop 16 -> theme.spacing(2). Only spacing value in Input root.
InputAdornment filled start marginTop 16 -> spacing(2) (tracks filled
label-space); horizontal margins stay literal with the input inline layout.
NativeSelect/Select audited: paddingRight 24/32 is icon-anchored geometry,
nothing to derive.
Standard resting y tracks Input marginTop + InputBase paddingTop:
spacing(3)-4px (md), spacing(2)+1px (sm). Shrunk -1.5px floats above -> literal.
…orm family

FormControl margin normal/dense -> spacing (static styled wrapped in memoTheme).
FormControlLabel row gaps 16 -> spacing(2); -11 stays literal (control padding).
FormHelperText/FormLabel audited skip. Completes the input/form family.
IconButton padding 8/5/12 -> spacing(1) / spacing(1)-3px / spacing(2)-4px.
fontSize + edge margins stay literal. ButtonBase: only resets, nothing to derive.
… Chip

Fab extended 0 16px/0 8px -> 0 spacing(2)/0 spacing(1); circle + sizes are
geometry. ButtonGroup: border-overlap + minWidth only. Chip: height-fixed
geometry, inline padding anchored-coupled, no block spacing.
ToggleButton padding 11/7/15 -> spacing-based. Tab padding 12px 16px +
labelIcon paddingTop/Bottom 9 + stacked-icon margin 6 -> spacing-based.
Tab jsdom computed-style test skipped (calc). ToggleButtonGroup: border overlap.
… PaginationItem

Inline paddings + margins -> spacing-based; geometry (height/minWidth) literal.
Pagination root skip (resets). Completes buttons & actionable controls.
List/ListItem/ListItemButton/MenuItem block paddings + ListItemText/Avatar/Icon
margins -> spacing-based. Inline (16/56/72/36/52) kept literal: anchored
horizontal alignment system. List/ListItemAvatar/ListItemText static -> memoTheme.
ListSubheader skip (horizontal only).
…ts & menus

Dropdown block paddings + input-integration block paddings -> spacing-based,
redistribution preserved (root 9 + inner 7.5 sums to OutlinedInput total).
Inline kept literal (icon-anchored reservation). Static root -> memoTheme.
Accordion margins, Alert paddings (icon/message/action derived together),
Dialog Title/Content/Actions paddings -> spacing-based. Dialog paper margin
skipped (coupled to static media-query breakpoints). Several static -> memoTheme.
…readcrumbs, MobileStepper

Finishes surfaces & containers. Card/Snackbar paddings, Tooltip padding,
Breadcrumbs separator, MobileStepper padding -> spacing-based. Tooltip arrow
margins + Card action sub-unit margins kept literal. Static -> memoTheme.
…dding)

Stepper/Step gap+padding, StepButton padding/negative margin, StepLabel +
StepContent paddings -> spacing-based. Half-icon 12px + StepConnector geometry
kept literal. Several static -> memoTheme.
Cell paddings, pagination margins, sort-label icon margins -> spacing-based.
Checkbox-column paddings + select arrow reservation kept literal.
…yout

Badge/ImageListItemBar paddings -> spacing-based. ImageList gap (public prop),
AvatarGroup overlap (var-driven), Link (none) skipped.
Switch/Slider/LinearProgress/Skeleton/Divider/Typography/CssBaseline audited as
geometry/skip. All checklist groups done. Added recurring-decisions summary.
… left

Groups deferred work by nature: (A) anchored horizontal systems, (B) geometry->
spacing, (C) sub-unit nudges, (D) media-query breakpoint coupling, (E) prop/var
driven, (F) em/rem + resets. Each item lists the exact literal values remaining.
Compensation negative now tracks IconButton padding at every --mui-spacing.
Both marginLeft (end placement) and marginRight (start placement) derived.

Baseline refreshed: literal -11 vs calc(spacing(-1) - 3px) is mathematically
equal at default but the browser resolves calc via var(--mui-spacing) with
sub-pixel precision differences from the integer literal -> ~1% anti-aliasing
diff. Equality at default is by construction; refreshed baseline anchors the
strict gate to the calc form for future changes.

Workflow refinement documented in the plan.
Every value (height/borderRadius, avatar/icon width+height, all the small
5/-6/4/-4/2/3 horizontal offsets, deleteIcon fontSize 22/16, label paddings)
maps to spacing(N) +/- offset via a coupled formula. The whole pill scales
together with --mui-spacing: at 10px the chip is 40px tall with 30px avatar
and proportional gaps. Pixel-identical at default.

ChipLabel static -> memoTheme. Refines the 'sub-unit literal' rule: when the
small values are load-bearing in a coupled system, derive them via the formula.
ListItem / ListItemButton / ListSubheader gutter paddingLeft/Right derived
together. The three parallel containers now stay aligned at every --mui-spacing
(was the horizontal-coupling reason for iter-1's 'literal' decision).

Still literal: ListItemText inset 56, ListSubheader inset 72, ListItem
secondaryAction paddingRight 48, ListItemIcon/Avatar widths 36/56 -- all
anchored to icon geometry, deferred to a coordinated icon-width derivation.
Match the +2/unit step you set on chip height. Medium avatar width/height
spacing(3) -> calc(8px + spacing(2)) (=24); medium deleteIcon fontSize
calc(spacing(3) - 2px) -> calc(6px + spacing(2)) (=22); medium icon gets
explicit fontSize calc(8px + spacing(2)) so it scales with the avatar (was
inheriting SvgIcon default 24). Small variants already had the right step.

Constant 8px chip-vs-avatar gap preserved at every density (centering exact).
Pixel-identical at default.
theme.spacing(N) inside calc(literal + spacing(N)) is the step coefficient:
each 1px change in --mui-spacing shifts the value by N px. Pick N from the
element's size/role, not just round(P/8):
- larger / container-like elements -> larger N (Chip height, Button padding N=2)
- smaller / inner glyphs -> smaller N (Chip avatar/icon/deleteIcon N=1)
At density the container scales faster than its contents, so inner gaps grow
instead of overflowing.

Wrap inner-positive offsets that must not go negative at ultra-low density in
max(..., 0px) (Chip avatar/icon marginLeft, deleteIcon marginRight).

Chip refined accordingly: height keeps N=2; avatar/icon/deleteIcon width/height/
fontSize switched to N=1 with literal offsets; min-clamp applied. Pixel-identical
at default; small-density (6px) chips stay proportional.
Internal SwitchBase used by Checkbox + Radio (Switch overrides padding).
+1/unit step deliberately matches FormControlLabel marginLeft's -1/unit step
(calc(spacing(-1) - 3px)). The two-axis sum stays constant -2px at every
--mui-spacing, so checkbox/radio glyphs hold their exact visual position
relative to FormControlLabel's edge across all densities. Pixel-identical
at default.
@code-infra-dashboard

Copy link
Copy Markdown

Deploy preview

https://deploy-preview-48585--material-ui.netlify.app/

Bundle size

Bundle Parsed size Gzip size
@mui/material 🔺+5.28KB(+1.02%) 🔺+512B(+0.34%)
@mui/lab 0B(0.00%) 0B(0.00%)
@mui/private-theming 0B(0.00%) 0B(0.00%)
@mui/system 0B(0.00%) 0B(0.00%)
@mui/utils 0B(0.00%) 0B(0.00%)

Details of bundle changes


Check out the code infra dashboard for more information about this PR.

@github-actions github-actions Bot added the PR: out-of-date The pull request has merge conflicts and can't be merged. label Jun 3, 2026
@siriwatknp

Copy link
Copy Markdown
Member Author

closed in favor of #48624

@siriwatknp siriwatknp closed this Jun 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

PR: out-of-date The pull request has merge conflicts and can't be merged.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant