Applied Module 12 · AI-Accelerated Government Development

Accessibility in the Real Stack

What you'll learn

~30 min
  • Identify MUI 7 accessibility gaps that require manual intervention beyond built-in aria attributes
  • Audit and fix color contrast issues where Tailwind utility classes override MUI theme tokens
  • Ensure DensityGate tiers are independently navigable via keyboard and screen reader
  • Integrate axe-core with Playwright for automated WCAG 2.1 AA audits in CI

Accessibility is a state requirement

WCAG 2.1 AA is not optional for state government applications. It is policy. When the accessibility office audits your application — and they will — they test with NVDA, JAWS, VoiceOver, and keyboard-only navigation. Automated scanning catches roughly 30% of issues. The rest require manual review.

The DS platform uses MUI 7 and Tailwind v4. MUI provides solid accessibility defaults: proper ARIA roles on dialogs, focus trapping in modals, keyboard-navigable menus. But the moment you layer Tailwind utility classes on top — custom colors, dark mode overrides, focus ring removals — you can break those defaults without knowing it.

This lesson covers the specific accessibility pitfalls of your stack and shows you how to use AI to find and fix them systematically.

💬Accessibility is not a checklist -- but a checklist helps

A passing axe-core scan does not mean your application is accessible. But a failing scan definitely means it is not. Automated audits catch the obvious problems (missing alt text, insufficient contrast, missing form labels) so you can spend your manual testing time on the harder problems (focus order, screen reader announcements, keyboard traps).


MUI 7 accessibility: what you get and what you do not

MUI 7 handles a lot out of the box. Understanding what it covers — and where it stops — keeps you from reinventing built-in behavior or missing gaps.

What MUI handles automatically:

  • role="dialog" and aria-modal="true" on Dialogs
  • Focus trapping inside open modals (Tab cycles within the dialog)
  • role="alert" on Snackbar content
  • aria-expanded on Accordion headers
  • role="listbox" and aria-selected on Select options
  • aria-sort on sortable DataGrid column headers

What MUI does NOT handle — you must add it:

  • aria-label on icon-only buttons (MUI renders <IconButton> without text)
  • aria-describedby linking error messages to form fields
  • aria-live regions for dynamic content updates (e.g., SWR data refreshes)
  • Meaningful alt text on images (MUI’s Avatar defaults to empty alt)
  • Skip navigation links for keyboard users
The IconButton trap

Every <IconButton> in the DS platform needs an aria-label. MUI does not generate one from the icon name. A Delete button rendering <DeleteIcon /> inside <IconButton> is invisible to screen readers without aria-label="Delete facility". This is the most common accessibility violation in MUI codebases.


Tailwind + MUI: where contrast breaks

The DS platform uses a dark theme. MUI’s dark palette and Tailwind’s dark mode utilities coexist through CSS layer ordering (you set this up in Lesson 3). But contrast violations creep in at the intersection:

Common failures

  1. Tailwind text-gray-400 on MUI dark backgrounds. Gray-400 (#9ca3af) on the DS platform’s card background (#1e293b) has a contrast ratio of 3.8:1. WCAG AA requires 4.5:1 for normal text. Fix: use text-gray-300 (#d1d5db) for a 7.2:1 ratio.

  2. MUI disabled state colors. MUI’s disabled text uses rgba(255,255,255,0.3) in dark mode. On your card background, this is roughly 2.1:1 contrast — well below the 4.5:1 minimum. Fix: override the disabled color in your theme to rgba(255,255,255,0.5) minimum.

  3. Focus-visible outlines. Tailwind’s focus:outline-none utility is commonly applied to clean up default browser outlines. If you do this without adding a replacement focus-visible:ring-2 style, keyboard users lose all focus indication. MUI components have built-in focus styles, but custom Tailwind buttons do not.

  4. DensityGate tab indicators. The active tab uses your accent color on a dark surface. If the accent is too saturated or too dark, the active indicator fails contrast against the tab bar background. Check both the text color and the indicator underline.

Audit our Next.js 15.5 project for WCAG 2.1 AA accessibility violations.
Focus on these areas specific to our stack:
1. MUI 7 IconButtons: find every <IconButton> that does NOT have an aria-label prop
2. Color contrast: check all Tailwind text-gray-* and text-slate-* classes used
on our dark backgrounds (#09090b, #111118, #18181b, #1e1e2a, #1e293b) for
4.5:1 minimum contrast
3. Focus indicators: find any element with focus:outline-none that does NOT have
a corresponding focus-visible:ring-* or focus-visible:outline-* class
4. Form fields: check that every MUI TextField has either a visible label or
aria-label, and that helperText error messages are linked via aria-describedby
5. Images and avatars: find <Avatar> and <img> elements missing meaningful alt text
6. Dynamic content: identify SWR data fetching patterns that update visible content
without aria-live regions
For each violation, provide:
- File path and line number
- Current code
- Fixed code
- WCAG success criterion reference (e.g., 1.4.3 Contrast)
Claude Code — Accessibility Audit
/home/dev/ds-platform $ claude 'Audit this project for WCAG 2.1 AA accessibility violations, focusing on MUI IconButtons, color contrast, focus indicators, and form labels'
I'll scan the codebase for accessibility issues. Found 14 violations across 8 files: --- IconButton missing aria-label (5 issues) --- src/components/FacilityRow.tsx:42 — <IconButton onClick={onDelete}><DeleteIcon /></IconButton> Fix: <IconButton aria-label="Delete facility" onClick={onDelete}> WCAG: 4.1.2 Name, Role, Value src/components/NavDrawer.tsx:18 — <IconButton onClick={toggleDrawer}><MenuIcon /></IconButton> Fix: <IconButton aria-label="Open navigation menu" onClick={toggleDrawer}> src/components/ThemeToggle.tsx:12 — <IconButton onClick={toggle}><DarkModeIcon /></IconButton> Fix: <IconButton aria-label="Toggle dark mode" onClick={toggle}> ... (2 more) --- Color contrast failures (4 issues) --- src/components/DensityGate.tsx:67 — text-slate-500 on bg #1e293b → ratio 3.2:1 Fix: text-slate-400 → ratio 4.8:1 WCAG: 1.4.3 Contrast (Minimum) ... (3 more) --- Focus indicator missing (3 issues) --- src/components/ui/ActionButton.tsx:8 — className="focus:outline-none" with no ring Fix: "focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500" WCAG: 2.4.7 Focus Visible ... (2 more) --- Form labels (2 issues) --- src/app/facilities/new/page.tsx:34 — TextField with placeholder but no label Fix: Add label="Facility Code" or aria-label="Facility Code" WCAG: 1.3.1 Info and Relationships ... (1 more) Shall I apply all fixes?
/home/dev/ds-platform $ yes, apply all 14 fixes
Applied 14 accessibility fixes: Updated: src/components/FacilityRow.tsx (1 fix) Updated: src/components/NavDrawer.tsx (1 fix) Updated: src/components/ThemeToggle.tsx (1 fix) Updated: src/components/UserMenu.tsx (2 fixes) Updated: src/components/DensityGate.tsx (2 fixes) Updated: src/components/ui/ActionButton.tsx (1 fix) Updated: src/components/ui/FilterChip.tsx (2 fixes) Updated: src/app/facilities/new/page.tsx (2 fixes) Updated: src/components/DataRefreshIndicator.tsx (2 fixes) All 14 WCAG 2.1 AA violations resolved.
/home/dev/ds-platform $

DensityGate accessibility

DensityGate is the platform’s most complex UI component from an accessibility perspective. Three tabs control which content tier is visible. Each tier contains different widgets. A screen reader user needs to understand the structure without seeing the visual layout.

The requirements:

  1. Tab panel association. Each tab must have aria-controls pointing to its panel’s id. Each panel must have role="tabpanel" and aria-labelledby pointing back to its tab.

  2. Keyboard navigation. Arrow keys move between tabs (Left/Right). Enter or Space activates. Home jumps to first tab, End to last. This is the WAI-ARIA Tabs pattern.

  3. Hidden panel content. Inactive panels should use hidden attribute (not display: none via CSS class alone) so screen readers skip them entirely. MUI’s TabPanel handles this if you use their component — but if DensityGate is a custom implementation, you must manage it.

  4. Live region on switch. When the user switches tiers, the new content should be announced. Add aria-live="polite" to the panel container so the screen reader reads the new content after the tab switch.

// DensityGate panel with proper ARIA
<div
role="tabpanel"
id={`density-panel-${tier}`}
aria-labelledby={`density-tab-${tier}`}
hidden={activeTier !== tier}
aria-live="polite"
>
{activeTier === tier && children}
</div>
💡Test with a real screen reader

Automated tools cannot verify that the screen reader experience makes sense. Spend 10 minutes with NVDA (free on Windows) or VoiceOver (built into macOS). Navigate the DensityGate tabs with keyboard only. If you cannot tell which tier is active and what content is showing without looking at the screen, the ARIA attributes need work.


Integrate axe-core with Playwright

Manual testing catches nuanced issues. Automated testing catches regressions. Combine them. @axe-core/playwright integrates directly with your existing Playwright tests from Lesson 13.

Add axe-core accessibility scanning to our Playwright E2E tests.
Requirements:
1. Install @axe-core/playwright
2. Create a reusable checkAccessibility() helper in e2e/helpers.ts that:
- Runs axe.analyze() on the current page
- Filters to WCAG 2.1 AA rules only (tags: wcag2a, wcag2aa, wcag21aa)
- Excludes known false positives (list them in e2e/axe-exclusions.ts)
- Throws with violation details if any issues found
3. Add checkAccessibility() call to key page tests:
- /dashboard (after full load)
- /facilities (after grid renders)
- /facilities/new (form page)
- Each DensityGate tier on /facilities
4. Add a standalone accessibility test file: e2e/accessibility.spec.ts
that navigates to every route and runs checkAccessibility()
e2e/helpers.ts
import AxeBuilder from '@axe-core/playwright';
import { expect, Page } from '@playwright/test';
export async function checkAccessibility(page: Page) {
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
.exclude('.known-third-party-widget') // MUI DatePicker has known issues
.analyze();
expect(results.violations).toEqual([]);
}
Claude Code — axe-core Integration
/home/dev/ds-platform $ npx playwright test e2e/accessibility.spec.ts
Running 8 tests using 1 worker ✓ accessibility — /dashboard passes axe audit (2.1s) ✓ accessibility — /facilities executive tier (1.8s) ✓ accessibility — /facilities operational tier (1.9s) ✓ accessibility — /facilities technical tier (2.0s) ✓ accessibility — /facilities/new form (1.6s) ✓ accessibility — /users admin page (1.7s) ✓ accessibility — /reports page (1.8s) ✓ accessibility — /settings page (1.5s) 8 passed (14.4s) 0 accessibility violations
/home/dev/ds-platform $
Run accessibility tests in your pipeline

Add the accessibility spec to your Azure Pipeline. When a developer adds a new page or component, the pipeline catches missing labels, broken contrast, and focus issues before the code reaches staging. This is cheaper than an accessibility audit finding after deployment.


The accessibility checklist for a DS platform module

When you build a new module (Lessons 5-6), run through this before marking it complete:

CheckHow to verifyWCAG criterion
All IconButtons have aria-labelSearch for <IconButton without aria-label4.1.2
Text contrast >= 4.5:1 on dark backgroundsChrome DevTools > Rendering > CSS contrast1.4.3
Large text contrast >= 3:1Same tool, text >= 18pt or 14pt bold1.4.3
Focus visible on every interactive elementTab through the page, verify visible ring2.4.7
Form fields have labels or aria-labelInspect each TextField, Select, Checkbox1.3.1
Error messages linked via aria-describedbySubmit invalid form, check error association1.3.1
DataGrid columns have aria-sortClick sortable column, inspect header4.1.2
DensityGate tabs follow WAI-ARIA Tabs patternKeyboard navigate with arrow keys2.1.1
Dynamic content has aria-live regionTrigger SWR refresh, check announcements4.1.3
Skip navigation link presentTab once from top of page2.4.1
Page has a single h1Inspect heading hierarchy1.3.1
Color is not the only indicatorCheck error states, status badges1.4.1

KNOWLEDGE CHECK

You have an MUI IconButton that renders a DeleteIcon. The button has an onClick handler and a red Tailwind color class. A screen reader user tabs to it. What do they hear?


Key takeaways

  • MUI 7 gives you a head start, not a finish line. ARIA roles on dialogs and menus are automatic. Labels on IconButtons, contrast ratios on custom colors, and live regions for dynamic content are your responsibility.
  • Tailwind dark mode and MUI dark theme interact. Every text-gray-* and text-slate-* class needs a contrast check against your actual background colors, not just theoretical Tailwind defaults.
  • DensityGate must follow the WAI-ARIA Tabs pattern. Three tiers means three tabpanels with proper aria-controls, aria-labelledby, keyboard navigation, and hidden management.
  • axe-core in Playwright catches regressions automatically. Run it on every page, in every pipeline. It takes 2 seconds per page and catches the violations that turn into audit findings.
  • 10 minutes with a real screen reader teaches you more than reading any spec. Do it.

What’s next

You can test new code and verify accessibility. In the next lesson, you will apply everything from Lane 2 and Lane 3 to a real challenge: refactoring a legacy module that predates the platform standards — raw SQL, no response helpers, no withPermission(), no DensityGate.