Agent Skills: MUI Accessibility (a11y)

MUI accessibility patterns, ARIA attributes, and WCAG compliance

UncategorizedID: lobbi-docs/claude/accessibility

Install this agent skill to your local

pnpm dlx add-skill https://github.com/markus41/claude/tree/HEAD/plugins/mui-expert/skills/accessibility

Skill Files

Browse the full folder contents for accessibility.

Download Skill

Loading file tree…

plugins/mui-expert/skills/accessibility/SKILL.md

Skill Metadata

Name
accessibility
Description
MUI accessibility patterns, ARIA attributes, and WCAG compliance

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.

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)