@mui/material
Material UI is a React component library that implements Google's Material Design: roughly a hundred finished, accessible components covering buttons, forms, tables, dialogs, navigation, feedback, and layout. You get them styled and keyboard-accessible out of the box, plus a theme object that centralizes palette, typography, spacing, breakpoints, and per-component defaults. Styling happens through three layers you can mix: the sx prop for one-off styles with theme-aware shorthands, styled() for reusable styled components, and theme.components overrides for changing a component everywhere at once. Under the hood it uses Emotion as the styling engine, which means CSS-in-JS at runtime. It is the most used React component library by a wide margin, and it sits alongside MUI X, a separate suite that adds the data grid, date pickers, charts, and tree view.
For an internal tool, dashboard, or admin app in React, Material UI still gets you further in a week than anything else, and the docs and hiring pool back that up. Reach for something lighter if you are shipping a public marketing site, going server-component first, or planning a visual identity that is not Material.
Use it if
- You are building an admin panel, internal tool, or dashboard where you need dozens of finished components tomorrow and a coherent look matters more than a distinctive one
- Your team is React plus TypeScript and wants first-party types everywhere: theme augmentation, per-component prop types, and typed slots and slotProps rather than a bag of className strings
- You want one theme object to control palette, typography scale, spacing, shape, breakpoints, and per-component defaults, so a design change is a diff in one file instead of a sweep across components
- You need Material Design specifically, for example a web app that has to match an Android product or a Google-adjacent design system
- You expect turnover or handover. It is the React component library the largest number of developers have already used, so onboarding cost is low and almost every question has an existing answer
- You know a data grid or date picker is coming and want a paid escape hatch (MUI X Pro and Premium) instead of discovering later that your free library cannot do row grouping
- Bundle size is a product requirement. Bundlephobia reports the package's main bundle at about 530 KB minified and 149.7 KB gzipped before Emotion and before your own code. Tree shaking removes a lot of that in a real app, but the floor is high for a marketing site or an embeddable widget, where Tailwind with Base UI or Radix ships a fraction of the bytes
- You are going all in on React Server Components. Emotion is a runtime CSS-in-JS engine and Material UI components are client components, so a Next.js App Router build means 'use client' boundaries, plus @mui/material-nextjs and @emotion/cache for the SSR cache provider. Pigment CSS, MUI's zero-runtime answer, is still an optional peer dependency rather than the default
- You do not want a Material Design look. Ripples, elevation shadows, uppercase button labels, and the shape scale are opinionated defaults, and stripping them means override work at nearly every component. Mantine, Chakra, or an unstyled kit starts from somewhere more neutral
- You cannot absorb major-version upgrades. The current migration guide walks you from v7 straight to v9, and v9 removes deprecated props and CSS classes across roughly fifty components (GridLegacy gone, TextField's InputProps replaced by slotProps, system shorthands like mt and color removed from Box, Stack, Typography, Grid, and Link). Codemods exist and you still have to run and review them
- You need a small backlog. There are 1382 open issues and 114 open PRs; a wide surface plus a huge user base means niche bugs and edge-case accessibility reports can sit for a long time
- You expect Grid to solve arbitrary layouts. It is flexbox based, does not support row spanning or auto-placement, and v9 removed direction="column" entirely, so vertical stacking goes to Stack and anything genuinely two dimensional goes to plain CSS Grid
- Your budget cannot include a commercial license and your requirements include a serious data table. Column pinning, row grouping, and Excel export live in MUI X Pro and Premium, not in the free component set
Setup reality
The install is npm install @mui/material @emotion/react @emotion/styled. Emotion is listed as an optional peer dependency but you need it unless you deliberately swap in @mui/styled-engine-sc with styled-components, which the docs tell you not to do for server-rendered apps. React 17, 18, and 19 are supported peers, but on React 18 or below you must pin react-is to your React version through overrides or resolutions, because Material UI depends on react-is 19 and mismatched copies break element type checks at runtime. The Roboto font is not bundled: install @fontsource/roboto and import the 300, 400, 500, and 700 weights, or add the Google Fonts link, or your typography quietly falls back to system fonts. Icons are a separate install (@mui/icons-material), and both packages should be imported by path (import Button from '@mui/material/Button') rather than from the barrel, because barrel imports make dev startup and rebuilds noticeably slower even though production bundlers tree shake fine. Next.js 13.5 and later handle that automatically via optimizePackageImports. On the App Router you also want @mui/material-nextjs plus @emotion/cache and the AppRouterCacheProvider wrapper so styles land in the head instead of the body. Finally, keep the family in lockstep: @mui/material, @mui/system, @mui/utils, @mui/icons-material, @mui/styled-engine, and @mui/material-nextjs all need to be on matching 9.x versions.
Patterns
Wire up the theme and baseline stylestheme-provider-setup
import { ThemeProvider, createTheme } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import Button from '@mui/material/Button';
const theme = createTheme({
cssVariables: true,
palette: { primary: { main: '#1976d2' } },
shape: { borderRadius: 8 },
});
export default function App() {
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<Button variant="contained">Save</Button>
</ThemeProvider>
);
}CssBaseline has to render inside ThemeProvider or it applies default colors rather than yours. Import components by path as shown; the barrel import from '@mui/material' works but slows dev startup and rebuilds.
Style one instance with the sx propsx-prop-styling
import Box from '@mui/material/Box';
<Box
sx={{
mt: 2,
p: { xs: 1, md: 3 },
color: 'text.secondary',
bgcolor: 'background.paper',
borderRadius: 1,
'&:hover': { bgcolor: 'action.hover' },
}}
/>Numeric spacing values are multiples of theme.spacing (2 means 16px by default), object values are breakpoint maps, and strings like 'text.secondary' resolve against the palette. In v9 the old shorthand props are gone: mt={2} on Box, Stack, Grid, Link, or Typography has to move inside sx.
Support light, dark, and system with a toggledark-mode-toggle
import { ThemeProvider, createTheme, useColorScheme } from '@mui/material/styles';
const theme = createTheme({ cssVariables: true, colorSchemes: { dark: true } });
function ModeToggle() {
const { mode, setMode } = useColorScheme();
if (!mode) {
return null;
}
return (
<select value={mode} onChange={(e) => setMode(e.target.value)}>
<option value="system">System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
);
}
<ThemeProvider theme={theme} defaultMode="system" disableTransitionOnChange>
<ModeToggle />
</ThemeProvider>mode is undefined on the very first render, so the early return is not optional: skip it and you get a hydration mismatch. Use colorSchemes rather than palette.mode if you want cross-tab syncing and system preference, and style dark variants with theme.applyStyles('dark', {...}) instead of checking theme.palette.mode, which flickers during SSR.
Lay out a responsive gridresponsive-grid
import Grid from '@mui/material/Grid';
import Stack from '@mui/material/Stack';
<Grid container spacing={2}>
<Grid size={{ xs: 12, md: 8 }}>main</Grid>
<Grid size={{ xs: 12, md: 4 }}>sidebar</Grid>
<Grid size="grow" offset={{ md: 2 }}>fills the rest</Grid>
</Grid>
<Stack spacing={2} sx={{ alignItems: 'center' }}>
<div>stacked</div>
</Stack>There is no item prop and no xs or md props any more; widths go in size. Grid only subdivides into columns, so v9 rejects direction="column": use Stack for vertical layout. Grid also has no row spanning and no auto-placement, so genuinely two-dimensional layouts belong in CSS Grid.
Change a component everywhere from the themetheme-component-overrides
const theme = createTheme({
components: {
MuiButton: {
defaultProps: { disableElevation: true, size: 'small' },
styleOverrides: {
root: {
textTransform: 'none',
variants: [
{
props: { variant: 'contained', color: 'primary' },
style: { boxShadow: 'none' },
},
],
},
},
},
},
});defaultProps changes behavior, styleOverrides changes CSS, and variants (nested inside the slot, not at the component level) applies styles conditionally on props. Order matters inside the variants array: later entries win.
Build a reusable styled componentstyled-component
import { styled } from '@mui/material/styles';
import Paper from '@mui/material/Paper';
const Panel = styled(Paper, {
shouldForwardProp: (prop) => prop !== 'tone',
})(({ theme, tone }) => ({
padding: theme.spacing(2),
borderLeft: `4px solid ${theme.palette[tone].main}`,
...theme.applyStyles('dark', {
backgroundColor: theme.vars.palette.grey[900],
}),
}));
<Panel tone="warning" elevation={0}>heads up</Panel>Import styled from '@mui/material/styles', not from '@emotion/styled', or the theme argument is empty. Without shouldForwardProp a custom prop like tone leaks onto the DOM and React logs an unknown-attribute warning. theme.vars only exists when cssVariables is enabled.
Reach into a TextField's inner elementstext-field-slot-props
import TextField from '@mui/material/TextField';
import InputAdornment from '@mui/material/InputAdornment';
<TextField
label="Amount"
error={Boolean(error)}
helperText={error ?? 'Pre-tax'}
slotProps={{
input: {
startAdornment: <InputAdornment position="start">$</InputAdornment>,
},
htmlInput: { maxLength: 10, inputMode: 'decimal' },
inputLabel: { shrink: true },
}}
/>v9 removed InputProps, inputProps, InputLabelProps, SelectProps, and FormHelperTextProps; they are all keys under slotProps now. The old lowercase inputProps is slotProps.htmlInput (attributes on the actual input element), while slotProps.input targets the wrapper component. Run npx @mui/codemod@latest deprecations/text-field-props to convert existing code.
Control when a dialog is allowed to closedialog-close-reason
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogActions from '@mui/material/DialogActions';
import Button from '@mui/material/Button';
function handleClose(event, reason) {
if (reason === 'escapeKeyDown' || reason === 'backdropClick') {
return;
}
setOpen(false);
}
<Dialog open={open} onClose={handleClose} fullWidth maxWidth="sm">
<DialogTitle>Delete project</DialogTitle>
<DialogActions>
<Button onClick={() => setOpen(false)}>Cancel</Button>
</DialogActions>
</Dialog>disableEscapeKeyDown was removed in v9, so filtering on the reason argument is now the only way to block escape. Note that onClose does not fire at all for the Cancel button; you close that path yourself.
Autocomplete backed by a remote searchautocomplete-async
import * as React from 'react';
import Autocomplete from '@mui/material/Autocomplete';
import TextField from '@mui/material/TextField';
import CircularProgress from '@mui/material/CircularProgress';
<Autocomplete
options={options}
loading={loading}
getOptionLabel={(o) => o.name}
isOptionEqualToValue={(o, v) => o.id === v.id}
onInputChange={(event, value) => search(value)}
filterOptions={(x) => x}
renderInput={(params) => (
<TextField
{...params}
label="User"
slotProps={{
...params.slotProps,
input: {
...params.slotProps.input,
endAdornment: (
<React.Fragment>
{loading ? <CircularProgress color="inherit" size={20} /> : null}
{params.slotProps.input.endAdornment}
</React.Fragment>
),
},
}}
/>
)}
/>In v9 the object passed to renderInput carries slotProps rather than the old InputProps and inputProps, so spread params.slotProps first or you lose the clear button and the popup arrow. isOptionEqualToValue is required for object options; without it React warns and the selected value never highlights. filterOptions={(x) => x} turns off the client-side filter, which is what you want when the server already filtered, and you debounce onInputChange yourself because the component does not.
Set it up in a Next.js App Router projectnextjs-app-router
// app/theme.ts
'use client';
import { createTheme } from '@mui/material/styles';
export default createTheme({ cssVariables: true, colorSchemes: { dark: true } });
// app/layout.tsx
import { AppRouterCacheProvider } from '@mui/material-nextjs/v15-appRouter';
import { ThemeProvider } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import theme from './theme';
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<AppRouterCacheProvider>
<ThemeProvider theme={theme}>
<CssBaseline />
{children}
</ThemeProvider>
</AppRouterCacheProvider>
</body>
</html>
);
}Install @mui/material-nextjs and @emotion/cache for this, and match the import path to your Next.js major (v15-appRouter, v14-appRouter, and so on). The theme file needs 'use client' because createTheme returns functions that cannot cross the server boundary. Without AppRouterCacheProvider the styles get injected into the body during streaming.
Branch on viewport width in JavaScriptresponsive-breakpoints
import useMediaQuery from '@mui/material/useMediaQuery';
function Nav() {
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md'));
return isDesktop ? <SideBar /> : <BottomBar />;
}The hook needs a ThemeProvider above it; there is no implicit default theme. It renders twice under SSR (once with the server value, once resolved), so prefer CSS-level responsiveness through sx or the Box display props when you can, and pass { noSsr: true } only for client-only trees. jsdom has no matchMedia, so tests need a polyfill.
Add a new variant and teach TypeScript about itcustom-variant-typescript
// theme.ts
declare module '@mui/material/Button' {
interface ButtonPropsVariantOverrides {
dashed: true;
}
}
const theme = createTheme({
components: {
MuiButton: {
styleOverrides: {
root: {
variants: [
{
props: { variant: 'dashed' },
style: { textTransform: 'none', border: '2px dashed currentColor' },
},
],
},
},
},
},
});
<Button variant="dashed">Import CSV</Button>The module augmentation has to live in a file that is part of your TypeScript program and imports something from MUI, otherwise the declare module block is ignored and variant="dashed" fails to typecheck. The same pattern with PaletteOptions adds custom palette colors.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| @mantine/core | npm | You want a similarly complete component set and hooks library with a less opinionated visual identity and CSS modules instead of runtime CSS-in-JS |
| antd | npm | You are building a dense enterprise back office and want a free data table, tree, and transfer list without a paid tier |
| @base-ui-components/react | npm | You want the accessibility and behavior primitives from the same team with no styles attached, so you can pair them with Tailwind or your own CSS |
| @chakra-ui/react | npm | You prefer a style-props API and a smaller, more composable set of components over a full Material Design implementation |