---
name: accessibility
description: MUI accessibility patterns, ARIA attributes, and WCAG compliance
triggers:
- accessibility
- a11y
- aria
- screen reader
- keyboard navigation
- WCAG
- focus
allowed-tools:
- Read
- Glob
- Grep
- Write
- Edit
globs:
- "*.tsx"
- "*.jsx"
---
# 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-checked` state attributes
- `aria-live` regions in Snackbar for announcements
## Icon Buttons Need aria-label
Icon-only buttons have no visible text — always add `aria-label`.
```tsx
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
// GOOD
// GOOD — dynamic label
// GOOD — using Tooltip (tooltip text does NOT replace aria-label for buttons)
```
## TextField Accessible Name
TextField with `label` is fully accessible. Without a label, add `aria-label` or `aria-labelledby`.
```tsx
// GOOD — label prop creates accessible name + visible label
// GOOD — label + helper text
// When label is hidden (search bar)
// Associating external label
Full name
```
## Dialog — Focus Trap and Labels
Dialog automatically traps focus. Always provide `aria-labelledby` and `aria-describedby`.
```tsx
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 (
);
}
```
## Menu Keyboard Navigation
Menu handles keyboard navigation automatically. Provide accessible trigger.
```tsx
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(null);
const open = Boolean(anchorEl);
return (
<>
>
);
}
```
**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
```tsx
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 (
{value === index && {children}}
);
}
function ProductTabs() {
const [value, setValue] = useState(0);
return (
setValue(newValue)}
aria-label="Product details"
>
Description contentSpecifications contentReviews content
);
}
```
**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"
```tsx
import Alert from '@mui/material/Alert';
// role="alert" — interrupts screen reader immediately (errors, warnings)
Payment failed. Please check your card details.
// role="status" (aria-live="polite") — announces when user is idle (success, info)
Profile saved successfully.
// Snackbar announces automatically via aria-live region
import Snackbar from '@mui/material/Snackbar';
setOpen(false)}
message="Changes saved"
/>
```
## Form Patterns — FormControl + FormLabel Chain
```tsx
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 (
Notification preferences}
label="Email notifications"
/>
}
label="SMS notifications"
/>
Choose at least one option
);
}
// Radio group
function PlanSelector() {
const [plan, setPlan] = useState('basic');
return (
Subscription plan setPlan(e.target.value)}
>
} label="Basic — $9/mo" />
} label="Pro — $29/mo" />
} label="Enterprise" />
);
}
```
## Icon Accessibility — Decorative vs Meaningful
```tsx
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)
}>
Save changes
// Meaningful icon — conveys unique information without adjacent text
// Icon with visible text — hide the icon
Your session will expire in 5 minutes
// Status indicator — use aria-label or visually-hidden text
Active
```
## 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
```tsx
// MUI default theme passes WCAG AA for primary, secondary, error, warning, success, info
// BAD — custom color with insufficient contrast on white background
Light gray text
// GOOD — use theme palette tokens which are contrast-tested
Primary textSecondary text (passes AA on white)
// When using custom colors, verify with a contrast checker
// theme.palette.grey[600] (#757575) has 4.6:1 on white — passes AA
Safe gray
// Disabled state — MUI uses lower contrast intentionally (WCAG exception for disabled)
```
## Focus Management
```tsx
import { useRef, useEffect } from 'react';
// Move focus to a heading after navigation (SPA route change)
function PageContent({ title }: { title: string }) {
const headingRef = useRef(null);
useEffect(() => {
headingRef.current?.focus();
}, [title]);
return (
{title}
);
}
// Move focus to first error on form submit failure
function FormWithFocusError() {
const firstErrorRef = useRef(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const errors = await validate();
if (errors.length) {
firstErrorRef.current?.focus();
}
};
return (
);
}
```
## Skip Navigation Link
For keyboard users to bypass repeated navigation:
```tsx
// Place at the very top of the page, before the app shell
function SkipNav() {
return (
Skip to main content
);
}
// In layout:
...
{children}
```
## Visually Hidden Text (Screen Reader Only)
```tsx
// 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
// Screen reader: "Delete Product XYZ, button"
// Visual user sees: "Delete"
```
## Loading States
```tsx
import CircularProgress from '@mui/material/CircularProgress';
// BAD — spinner with no accessible announcement
// GOOD — role + aria-label for standalone spinners
// GOOD — live region announces to screen reader when loading changes
{isLoading ? (
) : (
)}
// Button loading state (MUI Lab LoadingButton)
import LoadingButton from '@mui/lab/LoadingButton';
Save
```
## Common Violations and Fixes
| Violation | Fix |
|-----------|-----|
| Icon button without label | Add `aria-label` to `` |
| Input without label | Add `label` prop to `` or `inputProps={{ 'aria-label': '...' }}` |
| Dialog without aria-labelledby | Add `aria-labelledby` pointing to `` 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 `` with `` |
## Testing Accessibility
```bash
# 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(
{}} />
);
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)