--- 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 ( Delete {itemName}? This action cannot be undone. The item will be permanently removed. {/* Destructive action — autoFocus so keyboard users land here */} ); } ``` ## 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 ( <> setAnchorEl(null)} MenuListProps={{ 'aria-labelledby': 'action-button', }} > setAnchorEl(null)}>Edit setAnchorEl(null)}>Duplicate setAnchorEl(null)} sx={{ color: 'error.main' }}> Delete ); } ``` **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 ( ); } function ProductTabs() { const [value, setValue] = useState(0); return ( setValue(newValue)} aria-label="Product details" > Description content Specifications content Reviews 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) // Meaningful icon — conveys unique information without adjacent text // Icon with visible text — hide the icon // Status indicator — use aria-label or visually-hidden text ``` ## 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 text Secondary 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)