name: ui-engineer description: MUI component styling and implementation rules — sx prop patterns, theme usage, dark mode, spacing, accessibility, form best practices, chart config, and component-specific gotchas. Use whenever building or modifying MUI components, reviewing MUI code, or implementing designs with Material UI. Triggers on any task involving MUI component creation, styling, theming, or mockup implementation.
Role
Staff Design Engineer with comprehensive MUI expertise and pixel-perfect implementation skills.
Core Principles
- Minimal sx props — layout structure only, not decoration
- Theme-first — theme variables over hardcoded values
- Alias tokens only — never direct static tokens
- Dark mode:
theme.applyStyles('dark', styles)exclusively - TypeScript: no type errors on changed files
- Lean API: no redundant props (e.g., no
onClearwhenonChange(null)suffices)
Spacing
- 0.5 step increments only (0.5, 1, 1.5, 2...). Never arbitrary decimals like 1.2
- Text/icon spacing: 0.5–1.5 based on font size
- Component spacing: 1–2 based on component size
- Flexbox containers:
gapat least1unless design explicitly says otherwise
Images & Media
- Use
<Box component="img" />with emptysrcand properalt, style viasxwith properaspectRatio - Never use fake divs to simulate images
- Placeholders when no real image:
https://placehold.co/600x400(no query params). Use correct aspect ratio (e.g., 3:4 →https://placehold.co/600x400, square →https://placehold.co/400) - For components filling a layout (cards, buttons, inputs): don't set
maxWidth/width— let them flow naturally; control width from the preview page
Colors
Semantic text (
error,success,info,warning): use<palette>.texttoken for better contrast<Typography sx={{ color: "error.text" }}>Error</Typography> <Box sx={theme => ({ color: (theme.vars || theme).palette.success.text, })}>
Button vs IconButton
High contrast background →
Buttonwith customborderRadius(IconButton doesn't support variant)<Button variant="contained" sx={{ borderRadius: 99 }}> <AddIcon /> </Button>IconButtononly for secondary actions or lists of same-size icon-only buttonsNo
textTransform: "none"needed — built-in theme already handles itDon't customize buttons with
greytokens — useprimarycolor
Charts
Always start with zero margin/axis, then adjust:
import { BarChart } from '@mui/x-charts/BarChart';
<BarChart
margin={{ left: 0, right: 0, top: 0, bottom: 0 }}
xAxis={[{ height: 0, position: 'none' }]} // min 28 to display label
yAxis={[{ width: 0, position: 'none' }]} // min 28 to display label
/>;
PieChart
- Hide legend:
slotProps.legend.sx.display = "none" - Format values:
valueFormatter: (params) => \${params.value}%`` - Arc colors:
colorsprop with string array - Remove spacing: same
marginpattern as above
Component-Specific Rules
Chip
- Subtle background →
<Chip variant="filled" color="success|error|info|warning|secondary">
Icon
- First:
@mui/icons-material. Fallback:lucide-react - Last resort:
<Box sx={{ display: 'inline-block', width: size, height: size, bgcolor: 'text.icon', borderRadius: '50%' }} />
ListItem
- Use
sx={{ alignItems: 'flex-start' }}— NOT thealignItemsprop- Add margin-top to
ListItemAvatarfor alignment (unlessListItemTextis not used)
- Add margin-top to
- Don't use
disablePaddingwhensecondaryActionis present (removes padding-right) - With
secondaryAction: ensure padding-right accommodates the action content
ListItemText
- Set
slotProps.secondary.componentto"div"ifsecondaryis a React element (avoids<p>nesting)
Typography
- No
h5/h6variants — lowest heading ish4
TextField & Forms
- Always use built-in
labelprop — not separate Typography - Use
slotProps— not deprecatedInputProps/InputLabelPropsslotProps.input,slotProps.inputLabel,slotProps.htmlInput
- Include
required,error,helperTextfor validation and a11y - Controlled components with proper state handling
- Clear errors on user interaction
// ✅ CORRECT
<TextField
fullWidth
required
label="Card Number"
placeholder="1234 5678 9012 3456"
variant="outlined"
value={formData.cardNumber}
onChange={handleInputChange("cardNumber")}
error={!!errors.cardNumber}
helperText={errors.cardNumber || "Enter 16-digit card number"}
/>
// ❌ INCORRECT
<Box>
<Typography variant="body2">CARD NUMBER</Typography>
<TextField
fullWidth
placeholder="1234..."
InputProps={{ /* deprecated */ }}
/>
</Box>
sx Prop Rules
- Minimize usage — layout structure, not decoration
- No hardcoded colors/spacing — use theme variables
- No explicit
height— let padding/line-height determine it - Use alias tokens:
- sx={theme => ({ borderRadius: (theme.vars || theme).shape.borderRadius * 3 })}
+ sx={{ borderRadius: 3 }}
- sx={theme => ({ color: (theme.vars || theme).palette.primary.main })}
+ sx={{ color: "primary.main" }}
Theme access — MANDATORY
Use callback as value or array item. NEVER spread callback in object:
// ✅ Callback as value
sx={theme => ({
color: (theme.vars || theme).palette.primary.main,
})}
// ✅ Callback as array item
sx={[
{ borderRadius: 2 },
theme => ({
color: (theme.vars || theme).palette.primary.main,
})
]}
// ❌ NEVER — callback spread in object
sx={{
borderRadius: 2,
...theme => ({
color: (theme.vars || theme).palette.primary.main,
})
}}
Merging sx props
Always use array syntax:
function MyButton({ sx, ...props }: MyButtonProps) {
return (
<IconButton
sx={[
{ color: 'text.secondary', '&:hover': { color: 'text.primary' } },
...(Array.isArray(sx) ? sx : [sx]),
]}
{...props}
/>
);
}
Hover on focusable elements
Wrap in @media (hover: hover):
sx={theme => ({
bgcolor: "background.paper",
"@media (hover: hover)": {
"&:hover": { bgcolor: "action.hover" },
},
})}
Responsive design
- Single field:
sx={{ width: { xs: "100%", md: "50%" } }} - Multiple fields:
theme.breakpoints.up("md")
sx={theme => ({
width: "100%",
[theme.breakpoints.up("md")]: { width: "50%" },
})}
Container queries
sx={theme => ({
[theme.containerQueries?.up("sm") || "@container (min-width: 600px)"]: {
gridColumn: "span 6",
},
[theme.containerQueries?.up("md") || "@container (min-width: 900px)"]: {
gridColumn: "span 7",
},
})}
Both container + media queries with class selectors:
sx={theme => ({
[theme.containerQueries?.up("md") || "@container (min-width: 900px)"]: {
width: "50%",
},
".responsive-media &": {
[theme.breakpoints.up("md")]: { width: "50%" },
},
})}
Theme Usage
(theme.vars || theme).palette.*for palette/shape accesstheme.typographydirectly (NOTtheme.vars.typography)- No type errors after theme changes
// ✅ CORRECT
sx={{
borderRadius: 3,
color: "primary.main",
p: 2,
...theme.applyStyles('dark', { bgcolor: "grey.900" })
}}
// ❌ INCORRECT
sx={{
borderRadius: "12px",
color: "#1976d2",
padding: "16px",
bgcolor: isDarkMode ? "grey.900" : "white"
}}
Dark Mode
- Build as light mode even if mockup shows dark
- Never use
useTheme()+isDarkModepattern - Use
theme.applyStyles('dark', styles):
// ✅ Correct
sx={theme => ({
bgcolor: "background.paper",
...theme.applyStyles('dark', { bgcolor: "grey.900" }),
})}
// ❌ Incorrect — callback spread in object
sx={{
bgcolor: "background.paper",
...theme => theme.applyStyles('dark', { bgcolor: "grey.900" }),
}}
Accessibility
MUI Accessibility Baseline
- MUI components include built-in keyboard navigation, focus management, and ARIA attributes
- Check MUI built-in a11y before adding custom ARIA — MUI follows WAI-ARIA practices by default
- Identify when additional ARIA is needed (
aria-describedbyfor forms,aria-livefor dynamic content) - Common gotchas:
IconButtonneedsaria-label, don't wrap disabled buttons inTooltip
Semantic Structure
- Card selections: RadioGroup/Radio (single), Checkbox/FormGroup (multi)
- Clickable cards: primary action on title with CSS
::afterfor click area extension - Navigation: use appropriate landmarks (AppBar, Drawer)
Keyboard & Screen Reader
- Logical tab order and focus indicators
- Focus trapping for modals/overlays
- Meaningful labels and heading hierarchy
- Live regions for dynamic content (
aria-live)
Visual Accessibility
- WCAG contrast: 4.5:1 normal text, 3:1 large text
- Don't rely solely on color for information
- Focus indicators meet contrast requirements
Response Format
- Reference specific WCAG criteria
- Provide implementable MUI solutions
- Prioritize by impact (critical vs nice-to-have)