MUI (Material UI) Patterns
Quick Guide: MUI is a pre-styled React component library built on a theme.
createTheme+ThemeProviderdefine the tokens every component reads;sxstyles one instance,styled()makes a reusable one,theme.componentschanges every instance, andslots/slotPropsreach inner elements. Current: v7.x (March 2025) — CSS layers,slots/slotPropsstandardised everywhere, Grid v2 promoted toGrid, React 19 compatible. MUI X v8 covers DataGrid, DatePicker and Charts. Emotion is the styling engine; Pigment CSS is still alpha and on hold.
Detailed Resources:
- examples/core.md — theme with colour schemes, component overrides, dark-mode menu, TypeScript augmentation, SSR and SPA setup with CSS layers
- examples/styling.md —
styled(), custom props viashouldForwardProp,sxtheme-aware, responsive and callback forms - examples/form-inputs.md — TextField, Select, Autocomplete, slots and slotProps
- examples/layout.md — Grid, Stack, Box, Container, responsive card grid
- examples/navigation.md — AppBar, Drawer, Tabs, responsive dashboard shell
- examples/feedback.md — Dialog, Snackbar, Alert, Skeleton, CircularProgress
- examples/data-grid.md — DataGrid columns, pagination, cell rendering
- reference.md — component tables, theme structure,
sxshorthands, breakpoints, package list, v6 → v7 migration, anti-pattern code
Before writing MUI code
Wrap the app in ThemeProvider with a createTheme() instance. Without one, components fall
back to MUI's default theme and render correctly but wrong — no error, just someone else's design.
Import from the path, not the barrel — @mui/material/Button, not { Button } from "@mui/material". The barrel makes the dev server parse the whole package on first load, and
@mui/icons-material alone is over 2000 modules.
Use slots and slotProps to reach inner elements. They are the v7 API across every component;
components and componentsProps are deprecated and scheduled for removal.
Write dark-mode branches as theme.applyStyles("dark", { … }). It emits both rulesets and lets
CSS pick, so the server and the client agree. theme.palette.mode === "dark" decides at render time,
which is what makes the wrong theme flash before hydration.
Auto-detection: MUI, Material UI, @mui/material, @mui/system, @mui/icons-material, @mui/x-data-grid, @mui/x-date-pickers, createTheme, ThemeProvider, CssBaseline, sx prop, styled, useTheme, useColorScheme, colorSchemes, cssVariables, applyStyles, slots, slotProps, shouldForwardProp, GridColDef, StyledEngineProvider, enableCssLayer
Applies to:
- Building on MUI's component set and customising it through the theme rather than by forking
- Choosing between
sx,styled(),theme.componentsandslots/slotPropsfor a given override - Colour schemes and dark mode, including the CSS-variables route that survives server rendering
- Typing custom palette colours and typography variants through module augmentation
- MUI X — DataGrid, date pickers, charts
Handled elsewhere:
- CSS methodology and design-token architecture beyond MUI's own theme —
enableCssLayerputs MUI's styles in a named layer so an external stylesheet can order itself against them, and how that stylesheet is written is settled elsewhere - Unstyled primitives — this library arrives fully styled, and a project that wants to own every pixel wants headless primitives instead
- Form state and validation —
TextFieldtakeserrorandhelperTextto render a failure, andinputRefto hand the underlying<input>to a library that registers by ref; what decides those values is not this skill's concern - Server state — components take data as props
The theme is the product. Palette, typography, spacing, shadows, breakpoints, z-index and
transitions are all one object, and every component reads from it. An override written at a call
site is a decision that will need repeating; the same decision in theme.components is made once.
Customisation is layered, and the layer is the choice. sx for this instance, styled() for a
reusable variant, theme.components for every instance, slots/slotProps for elements inside a
component you do not own. Reaching for a heavier layer than the situation needs is the usual source
of style that cannot be changed later.
MUI components are client components. They use context and effects, so a server-rendered app
needs a cache provider above ThemeProvider and a client boundary around the pages that use them.
Which styling layer
Used in one place → sx prop
Reused, same shape each time → styled()
Every instance in the app → theme.components.MuiX
An element inside the component → slots / slotProps
Which layout component
One axis, even spacing → Stack
Twelve-column grid → Grid (size prop)
Centred page with a max width → Container
Anything else that needs sx → Box
Colour scheme
No dark mode → one palette in createTheme
Follow the system only → cssVariables: true
User can choose → colorSchemes + useColorScheme
Both, without a flash → cssVariables: { colorSchemeSelector: "data" } + useColorScheme
MUI X data display
Under ~100 rows, read-only → Table
Sorting, filtering, pagination → DataGrid
Very large datasets → DataGridPro (row virtualisation)
Date or time entry → DatePicker / DateTimePicker
Charts → MUI X Charts
Hierarchy → TreeView
Core patterns
Pattern 1: Theme and provider
const theme = createTheme({
palette: { primary: { main: "#1976d2" } },
typography: {
fontFamily: '"Inter", sans-serif',
button: { textTransform: "none" },
},
shape: { borderRadius: 8 },
spacing: 8,
});
<ThemeProvider theme={theme}>
<CssBaseline />
{children}
</ThemeProvider>;
CssBaseline applies MUI's normalisation and the theme's background and text colours to <body> —
without it the page around the components keeps the browser defaults.
Full code: examples/core.md
Pattern 2: CSS variables and dark mode
const theme = createTheme({
cssVariables: { colorSchemeSelector: "data" },
colorSchemes: {
light: { palette: { primary: { main: "#1976d2" } } },
dark: { palette: { primary: { main: "#90caf9" } } },
},
});
const { mode, setMode } = useColorScheme(); // "light" | "dark" | "system"
With cssVariables, both palettes are emitted as custom properties and a data-* attribute selects
between them, so the scheme is settled before React runs. mode is undefined on the first render —
guard on it before rendering anything that depends on the scheme.
Full code: examples/core.md
Pattern 3: The sx prop
<Box
sx={{
p: 3, // theme.spacing(3)
bgcolor: "background.paper", // theme.palette.background.paper
borderRadius: 1, // theme.shape.borderRadius
boxShadow: 3, // theme.shadows[3]
width: { xs: "100%", md: "50%" },
"&:hover": { boxShadow: 6 },
}}
/>
Numbers go through the theme's scales and dotted strings resolve against the palette, so a themed
value never needs to be repeated as a literal. Breakpoint objects replace media queries, and an
array of sx objects merges left to right — which is how a conditional style is added without
rebuilding the object.
Full code: examples/styling.md
Pattern 4: styled()
const StyledCard = styled(Card)(({ theme }) => ({
padding: theme.spacing(3),
"&:hover": { boxShadow: theme.shadows[8] },
...theme.applyStyles("dark", { backgroundColor: theme.palette.grey[900] }),
}));
Reach for this when the same styling appears more than once. A second argument takes
shouldForwardProp, which is what stops a styling-only prop reaching the DOM as an unknown
attribute.
Full code: examples/styling.md
Pattern 5: Slots and slotProps
<Autocomplete
slots={{ paper: CustomPaper }}
slotProps={{
paper: { elevation: 8, sx: { borderRadius: 2 } },
listbox: { sx: { maxHeight: 300 } },
input: ({ open }) => ({
sx: { borderColor: open ? "primary.main" : "divider" },
}),
}}
renderInput={(params) => <TextField {...params} label="Framework" />}
/>
slots replaces an inner component, slotProps configures one, and a slotProp written as a callback
receives that slot's own state. Define slot components outside the render — an inline arrow is a new
component type each time, which remounts the slot on every render.
Full code: examples/form-inputs.md
Pattern 6: Layout
<Grid container spacing={3}>
<Grid size={{ xs: 12, md: 4 }}><Sidebar /></Grid>
<Grid size={{ xs: 12, md: 8 }}><MainContent /></Grid>
</Grid>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<Button variant="contained">Save</Button>
</Stack>
<Container maxWidth="lg" sx={{ py: 4 }}>{children}</Container>
In v7 Grid is the former Grid2 and takes one size prop instead of separate xs/sm/md props;
the previous component is still available as GridLegacy.
Full code: examples/layout.md
Pattern 7: TypeScript augmentation
declare module "@mui/material/styles" {
interface Palette {
neutral: Palette["primary"];
}
interface PaletteOptions {
neutral?: PaletteOptions["primary"];
}
}
declare module "@mui/material/Button" {
interface ButtonPropsColorOverrides {
neutral: true;
}
}
Two augmentations, and both are needed: the first makes the token exist on the theme, the second
lets a component accept it as a prop value. Skip the second and color="neutral" is a type error at
every call site even though the theme carries it.
Full code: examples/core.md
Pattern 8: Server-rendered setup
<AppRouterCacheProvider options={{ enableCssLayer: true }}>
<GlobalStyles styles="@layer theme, base, mui, components, utilities;" />
<ThemeProvider theme={theme}>
<CssBaseline />
{children}
</ThemeProvider>
</AppRouterCacheProvider>
Emotion generates styles as components render, so a server-rendered app needs a cache provider above
ThemeProvider to collect and flush them with the streamed HTML — without it the markup arrives
unstyled and restyles on hydration. MUI publishes an adapter package per supported server framework;
a client-only app uses StyledEngineProvider in the same position instead. enableCssLayer puts
MUI's output into a named layer, and the @layer declaration is what fixes the order of that layer
against everything else on the page.
Full code, both arrangements: examples/core.md
Performance
Path imports. import Button from "@mui/material/Button" skips barrel parsing; the barrel form
costs several times the dev-server startup. Some bundlers and frameworks rewrite barrel imports
automatically — check before assuming the cost applies. A lint rule restricting the pattern
^@mui/[^/]+$ is what keeps it from creeping back.
Stable references for anything a component treats as identity. DataGrid columns, slot
components, and Dialog TransitionProps are all compared by reference: define them at module
scope, or memoise them. Inline, they cause a re-render or a remount on every parent render.
createTheme does not affect bundle size — it is data, not components. The bundle is decided by
which components are imported.
Red flags
Breaks at runtime:
theme.palette.mode === "dark"in a style function — resolved at render time, so the server and the first client paint disagree and the wrong theme flashes- An inline arrow in
slots— a new component type each render, so the slot remounts and loses its state and focus - Inline
columnson a DataGrid — a new array each render, re-rendering the whole grid - Importing more than one level deep,
@mui/material/styles/createTheme— the v7 ESM package layout does not expose those paths @mui/labimports for Alert, Skeleton or Autocomplete — they graduated to@mui/materialin v7- MUI components rendered on the server without a client boundary — they need context and effects, and the boundary belongs on the page, not only on the root layout
Surprising behaviour:
spacingis a multiplier, not pixels:spacing(2)is 16px at the default base of 8sxarrays merge left to right, so a later entry wins — which is what makes conditional styles workTextFieldis three components in a trench coat (input, label, helper text), so targeting the actual<input>meansslotProps.input- Setting
zIndexby hand fights MUI's own scale, where modal, drawer, snackbar and tooltip already have assigned values useMediaQueryreturnsfalseduring server rendering, so a desktop-first branch renders the wrong thing before hydrationuseColorScheme().modeisundefinedon the first render, and rendering a scheme-dependent icon from it produces a hydration mismatch- A missing TypeScript augmentation for a custom palette colour is a type error at the usage site, not at the theme
componentsandcomponentsPropsstill work in v7 but are deprecated