accessibility
MUI Accessibility (a11y)
MUI ships with many built-in accessibility features. This skill covers what works automatically, what you must add manually, common violations, and WCAG compliance patterns.
Built-in Accessibility Features
MUI provides these automatically:
role,aria-*attributes on interactive elements (Button, Checkbox, Slider, etc.)- Keyboard navigation in menus, selects, dialogs, date pickers, and tabs
- Focus trapping in Dialog and Drawer (via
FocusTrap) - Color contrast that meets WCAG AA for the default theme palette
aria-expanded,aria-selected,aria-checkedstate attributesaria-liveregions in Snackbar for announcements
Icon Buttons Need aria-label
Icon-only buttons have no visible text — always add aria-label.
import IconButton from '@mui/material/IconButton';
import DeleteIcon from '@mui/icons-material/Delete';
import EditIcon from '@mui/icons-material/Edit';
// BAD — screen reader announces nothing meaningful
<IconButton>
<DeleteIcon />
</IconButton>
// GOOD
<IconButton aria-label="Delete item">
<DeleteIcon />
</IconButton>
// GOOD — dynamic label
<IconButton aria-label={`Edit ${item.name}`}>
<EditIcon />
</IconButton>
// GOOD — using Tooltip (tooltip text does NOT replace aria-label for buttons)
<Tooltip title="Delete item">
<IconButton aria-label="Delete item">
<DeleteIcon />
</IconButton>
</Tooltip>
TextField Accessible Name
TextField with label is fully accessible. Without a label, add aria-label or aria-labelledby.
// GOOD — label prop creates accessible name + visible label
<TextField label="Email address" type="email" />
// GOOD — label + helper text
<TextField
label="Password"
type="password"
helperText="Minimum 8 characters"
inputProps={{ 'aria-describedby': 'password-helper-text' }}
/>
// When label is hidden (search bar)
<TextField
placeholder="Search..."
inputProps={{ 'aria-label': 'Search products' }}
/>
// Associating external label
<Typography id="name-label">Full name</Typography>
<TextField inputProps={{ 'aria-labelledby': 'name-label' }} />
Dialog — Focus Trap and Labels
Dialog automatically traps focus. Always provide aria-labelledby and aria-describedby.
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText';
import DialogActions from '@mui/material/DialogActions';
import Button from '@mui/material/Button';
function ConfirmDeleteDialog({ open, onClose, onConfirm, itemName }: Props) {
return (
<Dialog
open={open}
onClose={onClose}
aria-labelledby="confirm-dialog-title"
aria-describedby="confirm-dialog-description"
>
<DialogTitle id="confirm-dialog-title">
Delete {itemName}?
</DialogTitle>
<DialogContent>
<DialogContentText id="confirm-dialog-description">
This action cannot be undone. The item will be permanently removed.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
{/* Destructive action — autoFocus so keyboard users land here */}
<Button onClick={onConfirm} color="error" autoFocus>
Delete
</Button>
</DialogActions>
</Dialog>
);
}
Menu Keyboard Navigation
Menu handles keyboard navigation automatically. Provide accessible trigger.
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import Button from '@mui/material/Button';
import { useState } from 'react';
function ActionMenu() {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const open = Boolean(anchorEl);
return (
<>
<Button
id="action-button"
aria-controls={open ? 'action-menu' : undefined}
aria-haspopup="true"
aria-expanded={open ? 'true' : undefined}
onClick={(e) => setAnchorEl(e.currentTarget)}
>
Actions
</Button>
<Menu
id="action-menu"
anchorEl={anchorEl}
open={open}
onClose={() => setAnchorEl(null)}
MenuListProps={{
'aria-labelledby': 'action-button',
}}
>
<MenuItem onClick={() => setAnchorEl(null)}>Edit</MenuItem>
<MenuItem onClick={() => setAnchorEl(null)}>Duplicate</MenuItem>
<MenuItem onClick={() => setAnchorEl(null)} sx={{ color: 'error.main' }}>
Delete
</MenuItem>
</Menu>
</>
);
}
Keyboard behavior (automatic):
- Arrow Up/Down: navigate items
- Enter/Space: select item
- Escape: close menu and return focus to trigger
- Home/End: jump to first/last item
Tabs Keyboard Navigation
import Tabs from '@mui/material/Tabs';
import Tab from '@mui/material/Tab';
import Box from '@mui/material/Box';
interface TabPanelProps {
children?: React.ReactNode;
index: number;
value: number;
}
function TabPanel({ children, value, index }: TabPanelProps) {
return (
<div
role="tabpanel"
hidden={value !== index}
id={`product-tabpanel-${index}`}
aria-labelledby={`product-tab-${index}`}
>
{value === index && <Box sx={{ p: 3 }}>{children}</Box>}
</div>
);
}
function ProductTabs() {
const [value, setValue] = useState(0);
return (
<Box>
<Tabs
value={value}
onChange={(_, newValue) => setValue(newValue)}
aria-label="Product details"
>
<Tab label="Description" id="product-tab-0" aria-controls="product-tabpanel-0" />
<Tab label="Specifications" id="product-tab-1" aria-controls="product-tabpanel-1" />
<Tab label="Reviews" id="product-tab-2" aria-controls="product-tabpanel-2" />
</Tabs>
<TabPanel value={value} index={0}>Description content</TabPanel>
<TabPanel value={value} index={1}>Specifications content</TabPanel>
<TabPanel value={value} index={2}>Reviews content</TabPanel>
</Box>
);
}
Keyboard behavior (automatic):
- Arrow Left/Right: move between tabs
- Home/End: jump to first/last tab
- Enter/Space: activate focused tab
Alert — role="alert" vs role="status"
import Alert from '@mui/material/Alert';
// role="alert" — interrupts screen reader immediately (errors, warnings)
<Alert severity="error" role="alert">
Payment failed. Please check your card details.
</Alert>
// role="status" (aria-live="polite") — announces when user is idle (success, info)
<Alert severity="success" role="status">
Profile saved successfully.
</Alert>
// Snackbar announces automatically via aria-live region
import Snackbar from '@mui/material/Snackbar';
<Snackbar
open={open}
autoHideDuration={4000}
onClose={() => setOpen(false)}
message="Changes saved"
/>
Form Patterns — FormControl + FormLabel Chain
import FormControl from '@mui/material/FormControl';
import FormLabel from '@mui/material/FormLabel';
import FormGroup from '@mui/material/FormGroup';
import FormControlLabel from '@mui/material/FormControlLabel';
import FormHelperText from '@mui/material/FormHelperText';
import Checkbox from '@mui/material/Checkbox';
import RadioGroup from '@mui/material/RadioGroup';
import Radio from '@mui/material/Radio';
// Checkbox group — grouped with fieldset semantics
function NotificationPrefs() {
return (
<FormControl component="fieldset">
<FormLabel component="legend">Notification preferences</FormLabel>
<FormGroup>
<FormControlLabel
control={<Checkbox name="email" />}
label="Email notifications"
/>
<FormControlLabel
control={<Checkbox name="sms" />}
label="SMS notifications"
/>
</FormGroup>
<FormHelperText>Choose at least one option</FormHelperText>
</FormControl>
);
}
// Radio group
function PlanSelector() {
const [plan, setPlan] = useState('basic');
return (
<FormControl>
<FormLabel id="plan-label">Subscription plan</FormLabel>
<RadioGroup
aria-labelledby="plan-label"
value={plan}
onChange={(e) => setPlan(e.target.value)}
>
<FormControlLabel value="basic" control={<Radio />} label="Basic — $9/mo" />
<FormControlLabel value="pro" control={<Radio />} label="Pro — $29/mo" />
<FormControlLabel value="enterprise" control={<Radio />} label="Enterprise" />
</RadioGroup>
</FormControl>
);
}
Icon Accessibility — Decorative vs Meaningful
import SvgIcon from '@mui/material/SvgIcon';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import WarningIcon from '@mui/icons-material/Warning';
import Typography from '@mui/material/Typography';
// Decorative icon — hidden from screen readers
// (icon next to visible text — the text already conveys the meaning)
<Button startIcon={<SaveIcon aria-hidden="true" />}>
Save changes
</Button>
// Meaningful icon — conveys unique information without adjacent text
<CheckCircleIcon
aria-label="Verified"
sx={{ color: 'success.main' }}
/>
// Icon with visible text — hide the icon
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<WarningIcon aria-hidden="true" sx={{ color: 'warning.main' }} />
<Typography>Your session will expire in 5 minutes</Typography>
</Box>
// Status indicator — use aria-label or visually-hidden text
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<CheckCircleIcon sx={{ color: 'success.main' }} aria-hidden="true" />
<Typography component="span" sx={{ position: 'absolute', width: 1, height: 1, overflow: 'hidden', clip: 'rect(0,0,0,0)' }}>
Active
</Typography>
</Box>
Color Contrast (WCAG AA)
WCAG AA requires:
- Normal text: 4.5:1 contrast ratio
- Large text (18pt+ or 14pt+ bold): 3:1
- UI components (borders, icons): 3:1
// MUI default theme passes WCAG AA for primary, secondary, error, warning, success, info
// BAD — custom color with insufficient contrast on white background
<Typography sx={{ color: '#aaa' }}>Light gray text</Typography>
// GOOD — use theme palette tokens which are contrast-tested
<Typography color="text.primary">Primary text</Typography>
<Typography color="text.secondary">Secondary text (passes AA on white)</Typography>
// When using custom colors, verify with a contrast checker
// theme.palette.grey[600] (#757575) has 4.6:1 on white — passes AA
<Typography sx={{ color: 'grey.700' }}>Safe gray</Typography>
// Disabled state — MUI uses lower contrast intentionally (WCAG exception for disabled)
<Button disabled>Disabled</Button>
Focus Management
import { useRef, useEffect } from 'react';
// Move focus to a heading after navigation (SPA route change)
function PageContent({ title }: { title: string }) {
const headingRef = useRef<HTMLHeadingElement>(null);
useEffect(() => {
headingRef.current?.focus();
}, [title]);
return (
<Typography
variant="h1"
tabIndex={-1} // focusable programmatically, not in tab order
ref={headingRef}
sx={{ outline: 'none' }}
>
{title}
</Typography>
);
}
// Move focus to first error on form submit failure
function FormWithFocusError() {
const firstErrorRef = useRef<HTMLInputElement>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const errors = await validate();
if (errors.length) {
firstErrorRef.current?.focus();
}
};
return (
<form onSubmit={handleSubmit}>
<TextField
error
helperText="Required"
inputRef={firstErrorRef}
label="Email"
/>
</form>
);
}
Skip Navigation Link
For keyboard users to bypass repeated navigation:
// Place at the very top of the page, before the app shell
function SkipNav() {
return (
<Box
component="a"
href="#main-content"
sx={{
position: 'absolute',
left: '-9999px',
top: 'auto',
width: 1,
height: 1,
overflow: 'hidden',
'&:focus': {
position: 'static',
width: 'auto',
height: 'auto',
overflow: 'visible',
p: 1,
backgroundColor: 'primary.main',
color: 'primary.contrastText',
zIndex: 9999,
},
}}
>
Skip to main content
</Box>
);
}
// In layout:
<SkipNav />
<AppBar>...</AppBar>
<Box component="main" id="main-content" tabIndex={-1}>
{children}
</Box>
Visually Hidden Text (Screen Reader Only)
// Utility sx for visually hidden but screen-reader accessible text
const srOnly = {
position: 'absolute',
width: '1px',
height: '1px',
padding: 0,
margin: '-1px',
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
whiteSpace: 'nowrap',
border: 0,
} as const;
// Usage: add context for screen readers without showing it visually
<Button onClick={handleDelete}>
Delete
<Box component="span" sx={srOnly}> {itemName}</Box>
</Button>
// Screen reader: "Delete Product XYZ, button"
// Visual user sees: "Delete"
Loading States
import CircularProgress from '@mui/material/CircularProgress';
// BAD — spinner with no accessible announcement
<CircularProgress />
// GOOD — role + aria-label for standalone spinners
<CircularProgress aria-label="Loading products" />
// GOOD — live region announces to screen reader when loading changes
<Box aria-live="polite" aria-busy={isLoading}>
{isLoading ? (
<CircularProgress aria-label="Loading" />
) : (
<ProductList products={products} />
)}
</Box>
// Button loading state (MUI Lab LoadingButton)
import LoadingButton from '@mui/lab/LoadingButton';
<LoadingButton
loading={isSubmitting}
loadingIndicator="Saving..."
variant="contained"
>
Save
</LoadingButton>
Common Violations and Fixes
| Violation | Fix |
|---|---|
| Icon button without label | Add aria-label to <IconButton> |
| Input without label | Add label prop to <TextField> or inputProps={{ 'aria-label': '...' }} |
| Dialog without aria-labelledby | Add aria-labelledby pointing to <DialogTitle> id |
| Color as the only indicator | Add text, icon, or pattern alongside color |
| Tab panel not linked to tab | Use matching id/aria-controls / aria-labelledby |
| Low-contrast custom color | Check ratio; use theme.palette tokens |
| Missing focus outline | Never set outline: 0 without a :focus-visible replacement |
| Spinner with no announcement | Add aria-label or aria-live region |
| Tooltip replacing aria-label | Tooltip is not accessible — add explicit aria-label too |
| Form group without legend | Wrap in <FormControl component="fieldset"> with <FormLabel component="legend"> |
Testing Accessibility
# Automated: axe-core via jest-axe
npm install --save-dev jest-axe @types/jest-axe
# In tests:
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
test('DatePicker has no accessibility violations', async () => {
const { container } = render(
<LocalizationProvider dateAdapter={AdapterDayjs}>
<DatePicker label="Date" value={null} onChange={() => {}} />
</LocalizationProvider>
);
expect(await axe(container)).toHaveNoViolations();
});
Manual testing checklist:
- Tab through all interactive elements — focus order must be logical
- Press Enter/Space to activate buttons and links
- Press Escape to close dialogs, menus, and pickers
- Test with screen reader: NVDA+Firefox (Windows), VoiceOver+Safari (macOS/iOS)
- Zoom to 200% — layout must remain usable
- Test with keyboard only (unplug mouse)
More from lobbi-docs/claude
vision-multimodal
Vision and multimodal capabilities for Claude including image analysis, PDF processing, and document understanding. Activate for image input, base64 encoding, multiple images, and visual analysis.
243complex-reasoning
Multi-step reasoning patterns and frameworks for systematic problem solving. Activate for Chain-of-Thought, Tree-of-Thought, hypothesis-driven debugging, and structured analytical approaches that leverage extended thinking.
105debugging
Debugging techniques for Python, JavaScript, and distributed systems. Activate for troubleshooting, error analysis, log investigation, and performance debugging. Includes extended thinking integration for complex debugging scenarios.
59keycloak
Keycloak identity and access management including realms, clients, authentication flows, themes, and user federation. Activate for OAuth2, OIDC, SAML, SSO, identity providers, and authentication configuration.
54authentication
Authentication and authorization including JWT, OAuth2, OIDC, sessions, RBAC, and security analysis. Activate for login, auth flows, security audits, threat modeling, access control, and identity management.
53atlassianapi
Atlassian API integration for Jira and Confluence automation. Activate for Atlassian REST APIs, webhooks, and platform integration.
53