MUI v6 in React: Layout, the sx Prop, Custom Themes, Dark Mode and DataGrid
Key takeaways
MUI (formerly Material UI) is the most popular React component library — 90K+ GitHub stars, 3M weekly downloads. This guide covers MUI v6 from setup to custom themes and production patterns.
MUI v6 provides a complete React component library with consistent design, full TypeScript support, and deep customization. This guide covers the essential components, theming, and patterns for building production UIs.
Real-world insight: Using MUI’s DataGrid cut a complex sortable/filterable table feature from 3 weeks of custom development to 2 hours of configuration.
That tradeoff — comprehensive, pre-built components in exchange for less control over exact visual output — is the core decision MUI represents, and it’s worth being explicit about before diving into the API. A utility-first approach like Tailwind gives you a blank canvas and full control at the cost of building every component (sortable table, accessible modal, date picker) yourself; MUI gives you production-tested, WAI-ARIA-compliant components immediately, but customizing them beyond what the theming system exposes (covered below) generally means fighting the library rather than working with it. Teams building admin dashboards, internal tools, and data-heavy UIs — where consistency and speed matter more than a fully bespoke visual identity — are where MUI’s tradeoff pays off most clearly.
Installation
npm install @mui/material @emotion/react @emotion/styled
npm install @mui/icons-material # optional: 2000+ icons
MUI’s core package deliberately depends on Emotion (a CSS-in-JS library) rather than shipping plain CSS files, which is why @emotion/react/@emotion/styled are required peer dependencies, not optional extras — every MUI component’s styles are generated and injected at runtime via Emotion, which is also what makes the theming system (custom colors, spacing, typography propagating through every component automatically) possible without a build-time CSS preprocessing step. This runtime cost is real but usually small in practice; it’s the mechanism worth understanding before assuming MUI works like a plain CSS framework.
Basic Components
Button
import Button from '@mui/material/Button'
<Button variant="contained">Primary</Button>
<Button variant="outlined">Secondary</Button>
<Button variant="text">Text</Button>
<Button variant="contained" color="error" size="large">
Delete
</Button>
<Button variant="contained" startIcon={<DeleteIcon />} disabled>
Disabled
</Button>
variant, not custom classes, is how MUI expresses visual hierarchy: contained (filled background, highest visual weight) for primary actions, outlined for secondary actions, text for the lowest-emphasis actions like “Cancel” — this maps directly onto Material Design’s own guidance about which action on a screen should draw the eye most. color layers on top of variant independently (semantic meaning — error, primary, secondary — rather than a specific hex value), which is what lets a theme’s color palette (covered later) propagate consistently through every button in the app without each one hardcoding a color.
TextField (Form Input)
import TextField from '@mui/material/TextField'
<TextField label="Email" type="email" fullWidth required />
<TextField
label="Password"
type="password"
error={!!errors.password}
helperText={errors.password?.message}
fullWidth
/>
<TextField
label="Bio"
multiline
rows={4}
fullWidth
placeholder="Tell us about yourself"
/>
The error/helperText pair is the idiomatic MUI pattern for form validation feedback, and it’s worth wiring up correctly rather than rolling a separate error-message component: error toggles the field’s visual error state (red border, red label) and helperText displays underneath regardless of error state, switching its own color to match — using it this way, driven by a form library’s validation errors (errors.password?.message), means the visual error state and the message shown are always in sync, rather than two separately-managed pieces of UI that can drift apart.
Card
import Card from '@mui/material/Card'
import CardContent from '@mui/material/CardContent'
import CardActions from '@mui/material/CardActions'
<Card sx={{ maxWidth: 400 }}>
<CardMedia component="img" height="200" image="/cover.jpg" alt="cover" />
<CardContent>
<Typography variant="h5" component="h2">Card Title</Typography>
<Typography variant="body2" color="text.secondary">
Description text here
</Typography>
</CardContent>
<CardActions>
<Button size="small">Learn More</Button>
<Button size="small" color="error">Delete</Button>
</CardActions>
</Card>
Card, CardContent, CardActions, and CardMedia are separate components rather than one monolithic Card with props, and that decomposition is deliberate — each piece applies its own conventional spacing and layout (CardContent pads its children, CardActions lays out buttons in a row with the right gap), so composing them correctly gets you Material Design’s spacing conventions for free, without having to look up exact padding values yourself.
Layout Components
Stack (Flexbox)
import Stack from '@mui/material/Stack'
<Stack direction="row" spacing={2} alignItems="center" justifyContent="space-between">
<Typography>Left</Typography>
<Button>Right</Button>
</Stack>
<Stack spacing={3}>
<TextField label="Name" />
<TextField label="Email" />
<Button variant="contained">Submit</Button>
</Stack>
Stack is deliberately the simpler of MUI’s two layout primitives — it’s a thin, purpose-built wrapper around Flexbox for exactly one axis at a time (direction="row" or the default column), which is why it reaches for props like spacing and alignItems directly rather than exposing full CSS Grid-style two-dimensional layout. Reach for Stack first for straightforward one-directional arrangements (a form’s fields, a row of buttons); reach for Grid, covered next, once you need a responsive multi-column layout that reflows based on breakpoint.
Grid (Responsive Layout)
import Grid from '@mui/material/Grid'
<Grid container spacing={3}>
<Grid size={{ xs: 12, sm: 6, md: 4 }}>
<Card>Card 1</Card>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 4 }}>
<Card>Card 2</Card>
</Grid>
<Grid size={{ xs: 12, sm: 12, md: 4 }}>
<Card>Card 3</Card>
</Grid>
</Grid>
The size={{ xs: 12, sm: 6, md: 4 }} syntax is worth flagging specifically because it’s a breaking change from older MUI versions and a lot of tutorials/Stack Overflow answers still show the pre-v6 API (<Grid item xs={12} sm={6} md={4}>, with item as a separate boolean prop and each breakpoint as its own prop). The numbers themselves follow MUI’s 12-column grid convention regardless of API version: xs: 12 means “full width on extra-small screens,” md: 4 means “one-third width (4 of 12 columns) from the medium breakpoint up” — this is how the same markup produces a single column on mobile and a three-column layout on desktop without any manual media queries.
Container
import Container from '@mui/material/Container'
<Container maxWidth="lg">
{/* Content centered with max-width */}
</Container>
<Container maxWidth="sm">
{/* Narrow form container */}
</Container>
Container solves a narrower, specific problem than Grid: centering content horizontally with a responsive max-width, so text and layouts don’t stretch uncomfortably wide on large monitors. The maxWidth values (xs through xl) correspond to MUI’s breakpoint sizes, not arbitrary pixel values you pick — maxWidth="sm" caps the container at the same width as the sm breakpoint, which is why it’s the conventional choice for narrow, form-focused pages (login, checkout) where a full-width layout would look sparse.
The sx Prop
The sx prop applies styles inline using MUI’s theme tokens:
<Box
sx={{
p: 3, // padding: theme.spacing(3) = 24px
m: 2, // margin
mt: 4, // margin-top
bgcolor: 'primary.main', // theme color
color: 'white',
borderRadius: 2, // theme.shape.borderRadius * 2
boxShadow: 3, // theme.shadows[3]
display: 'flex',
alignItems: 'center',
gap: 2,
// Responsive
fontSize: { xs: '1rem', md: '1.25rem' },
// Pseudo-classes
'&:hover': { bgcolor: 'primary.dark' },
// Dark mode
'.dark &': { bgcolor: 'grey.800' },
}}
>
Styled Box
</Box>
The values here aren’t plain CSS — p: 3 doesn’t mean 3px, it means theme.spacing(3), which by default multiplies by 8px (so 24px), and bgcolor: 'primary.main' resolves through the active theme’s color palette rather than a literal color string. This indirection is the entire point: every sx value referencing the theme automatically updates if the theme changes (switching to dark mode, rebranding colors), without touching any component code, because the values are resolved from the theme at render time rather than hardcoded. The responsive object syntax (fontSize: { xs: '1rem', md: '1.25rem' }) and the &:hover pseudo-class support are both compiled by Emotion under the hood into real CSS with media queries and pseudo-selectors — sx isn’t inline styles in the traditional sense, even though it looks similar, which is exactly what makes pseudo-classes and responsive breakpoints work inside it at all (genuine inline style props can’t express either).
Custom Theme
// theme.ts
import { createTheme } from '@mui/material/styles'
export const theme = createTheme({
palette: {
primary: {
main: '#6366f1', // indigo
light: '#818cf8',
dark: '#4f46e5',
contrastText: '#fff',
},
secondary: {
main: '#ec4899', // pink
},
background: {
default: '#f8fafc',
paper: '#ffffff',
},
},
typography: {
fontFamily: '"Inter", "Roboto", sans-serif',
h1: { fontSize: '2.5rem', fontWeight: 700 },
h2: { fontSize: '2rem', fontWeight: 700 },
body1: { lineHeight: 1.7 },
},
shape: {
borderRadius: 8, // default for all components
},
components: {
// Override component defaults
MuiButton: {
styleOverrides: {
root: {
textTransform: 'none', // disable ALL_CAPS
fontWeight: 600,
},
containedPrimary: {
boxShadow: 'none',
'&:hover': { boxShadow: 'none' },
},
},
defaultProps: {
disableElevation: true,
},
},
MuiTextField: {
defaultProps: {
size: 'small',
variant: 'outlined',
},
},
},
})
The components key is where a theme goes from “colors and fonts” to genuinely controlling every instance of a component app-wide — MuiButton.styleOverrides.root here disables Material Design’s default all-caps button text globally, which is a change many teams make immediately since the all-caps convention doesn’t fit every brand’s visual style. defaultProps inside a component’s theme entry is a different, equally useful mechanism: rather than overriding styles, it changes the default prop values every instance of that component receives — setting MuiTextField.defaultProps.size: 'small' means every <TextField> in the app renders small unless a specific instance explicitly overrides it, which is a meaningfully more maintainable pattern than passing size="small" to every single TextField by hand.
// App.tsx
import { ThemeProvider, CssBaseline } from '@mui/material'
import { theme } from './theme'
export default function App() {
return (
<ThemeProvider theme={theme}>
<CssBaseline /> {/* normalize CSS */}
<Router />
</ThemeProvider>
)
}
CssBaseline is easy to skip since it looks optional, but it’s the component that applies a Material-Design-aligned CSS reset (consistent box-sizing, font smoothing, removing the browser’s default margin) — without it, MUI’s own components render correctly, but the surrounding page (body margins, default font) can look visibly inconsistent with the browser’s own default stylesheet showing through around them.
Dark Mode
// useColorMode.ts
import { createTheme } from '@mui/material'
import { useState, useMemo } from 'react'
export function useColorMode() {
const [mode, setMode] = useState<'light' | 'dark'>('light')
const theme = useMemo(() => createTheme({
palette: {
mode,
...(mode === 'dark' ? {
background: { default: '#0f172a', paper: '#1e293b' },
primary: { main: '#818cf8' },
} : {
background: { default: '#f8fafc', paper: '#ffffff' },
primary: { main: '#6366f1' },
}),
},
}), [mode])
const toggleMode = () => setMode(m => m === 'light' ? 'dark' : 'light')
return { theme, mode, toggleMode }
}
// palette.mode: 'dark' isn't just a label — setting it activates MUI's
// built-in dark-mode color logic for every component that reads palette
// tokens (text colors, dividers, elevation shadows all invert
// automatically), which is why this hook only needs to explicitly
// override background/primary and lets everything else adapt on its own.
// useMemo here matters for a subtle performance reason: createTheme()
// does real work building the full theme object, and without memoizing
// it, App would rebuild the entire theme from scratch on every render,
// not just when `mode` actually changes.
// App.tsx
export default function App() {
const { theme, mode, toggleMode } = useColorMode()
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<IconButton onClick={toggleMode}>
{mode === 'dark' ? <LightModeIcon /> : <DarkModeIcon />}
</IconButton>
{/* rest of app */}
</ThemeProvider>
)
}
Worth noting for a production version of this pattern: this example resets to 'light' on every page load and doesn’t persist the user’s choice — a real implementation would typically read an initial value from localStorage or the OS-level prefers-color-scheme media query, and write the user’s explicit choice back to localStorage inside toggleMode, so the preference survives a refresh instead of always starting from light mode.
Dialog / Modal
import { Dialog, DialogTitle, DialogContent, DialogActions } from '@mui/material'
function ConfirmDialog({ open, onClose, onConfirm, message }) {
return (
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
<DialogTitle>Confirm Action</DialogTitle>
<DialogContent>
<Typography>{message}</Typography>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button onClick={onConfirm} variant="contained" color="error">
Confirm
</Button>
</DialogActions>
</Dialog>
)
}
Dialog handles the accessibility work that’s easy to get wrong writing a modal by hand — focus trapping (keyboard focus can’t tab out to the page behind the dialog while it’s open), returning focus to the triggering element on close, closing on Escape, and rendering via a React portal so the dialog isn’t visually clipped by an ancestor’s overflow: hidden. onClose fires both from clicking the backdrop and from pressing Escape, which is convenient by default but worth guarding for destructive confirmations specifically — a confirm-delete dialog accidentally dismissed by an accidental backdrop click shouldn’t silently cancel a user’s intended action without them noticing, which is why some teams disable backdrop-click-to-close (onClose checking the close reason) on dialogs with real consequences.
Select, Autocomplete, DatePicker
// Select
<FormControl fullWidth>
<InputLabel>Country</InputLabel>
<Select value={country} onChange={e => setCountry(e.target.value)} label="Country">
<MenuItem value="kr">South Korea</MenuItem>
<MenuItem value="us">United States</MenuItem>
<MenuItem value="jp">Japan</MenuItem>
</Select>
</FormControl>
// Autocomplete (searchable select)
import Autocomplete from '@mui/material/Autocomplete'
<Autocomplete
options={['React', 'Vue', 'Angular', 'Svelte']}
renderInput={(params) => <TextField {...params} label="Framework" />}
onChange={(_, value) => setFramework(value)}
/>
// Date Picker (requires @mui/x-date-pickers)
import { DatePicker } from '@mui/x-date-pickers'
<DatePicker label="Start Date" value={date} onChange={setDate} />
Select needs to be wrapped in a FormControl with a matching InputLabel — this isn’t just convention, FormControl is what actually associates the label with the input for accessibility (screen readers announcing the field’s purpose) and handles the label’s shrink/float animation when a value is selected. Autocomplete is worth reaching for over a plain Select specifically once the option list gets long enough that scrolling through a dropdown becomes worse UX than typing to filter — it layers real-time text filtering on top of the same underlying selection behavior. The date picker is called out as requiring a separate package (@mui/x-date-pickers) because it’s part of MUI’s “X” component family — advanced, more specialized components (this one, plus DataGrid below) that are versioned and licensed somewhat separately from core @mui/material, including a paid “Pro” tier for some of their more advanced features.
DataGrid (Advanced Table)
npm install @mui/x-data-grid
import { DataGrid } from '@mui/x-data-grid'
const columns = [
{ field: 'id', headerName: 'ID', width: 70 },
{ field: 'name', headerName: 'Name', width: 150, editable: true },
{ field: 'email', headerName: 'Email', width: 200 },
{ field: 'role', headerName: 'Role', width: 120,
type: 'singleSelect', valueOptions: ['admin', 'user', 'viewer'] },
{
field: 'actions',
type: 'actions',
getActions: ({ id }) => [
<GridActionsCellItem icon={<EditIcon />} label="Edit" onClick={() => handleEdit(id)} />,
<GridActionsCellItem icon={<DeleteIcon />} label="Delete" onClick={() => handleDelete(id)} />,
],
},
]
<DataGrid
rows={users}
columns={columns}
pageSizeOptions={[10, 25, 50]}
checkboxSelection
disableRowSelectionOnClick
onRowSelectionModelChange={setSelectedIds}
sx={{ border: 0 }}
/>
This ties back to the opening “3 weeks to 2 hours” claim concretely: sorting, filtering, pagination (pageSizeOptions), row selection with checkboxes, and inline cell editing (editable: true) are all built into DataGrid and driven by declarative column config rather than hand-written state management and event handlers — the kind of feature set that would otherwise mean building (and testing, and maintaining) a fair amount of custom table logic. getActions on the actions column type is the specific mechanism for per-row action buttons (edit/delete icons); it’s called once per row with that row’s id, which is what lets each row’s action handlers close over the correct row identity without manual index-tracking.
Frequently Asked Questions (FAQ)
Q. Should I use the sx prop or styled() for custom styles?
A. Use sx for one-off tweaks on a specific element - spacing, a color from the theme, a breakpoint override - because it keeps the style next to the markup and still resolves theme tokens. When the same styled variant is reused across the app, or rendered many times (for example in every row of a large list), define it once with styled(). That gives you a named, reusable component, and it avoids processing a new sx object on each render.
Related Articles
- React from Usage to Internals: Fiber Reconciliation, Diffing, Hooks and Concurrent Rendering
- Tailwind CSS v4 in Practice