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.
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"andaria-modal="true"on Dialogs- Focus trapping inside open modals (Tab cycles within the dialog)
role="alert"on Snackbar contentaria-expandedon Accordion headersrole="listbox"andaria-selectedon Select optionsaria-sorton sortable DataGrid column headers
What MUI does NOT handle — you must add it:
aria-labelon icon-only buttons (MUI renders<IconButton>without text)aria-describedbylinking error messages to form fieldsaria-liveregions for dynamic content updates (e.g., SWR data refreshes)- Meaningful
alttext on images (MUI’sAvatardefaults to empty alt) - Skip navigation links for keyboard users
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
-
Tailwind
text-gray-400on 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: usetext-gray-300(#d1d5db) for a 7.2:1 ratio. -
MUI
disabledstate colors. MUI’s disabled text usesrgba(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 torgba(255,255,255,0.5)minimum. -
Focus-visible outlines. Tailwind’s
focus:outline-noneutility is commonly applied to clean up default browser outlines. If you do this without adding a replacementfocus-visible:ring-2style, keyboard users lose all focus indication. MUI components have built-in focus styles, but custom Tailwind buttons do not. -
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 prop2. 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 contrast3. Focus indicators: find any element with focus:outline-none that does NOT have a corresponding focus-visible:ring-* or focus-visible:outline-* class4. Form fields: check that every MUI TextField has either a visible label or aria-label, and that helperText error messages are linked via aria-describedby5. Images and avatars: find <Avatar> and <img> elements missing meaningful alt text6. 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)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:
-
Tab panel association. Each tab must have
aria-controlspointing to its panel’sid. Each panel must haverole="tabpanel"andaria-labelledbypointing back to its tab. -
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.
-
Hidden panel content. Inactive panels should use
hiddenattribute (notdisplay: nonevia CSS class alone) so screen readers skip them entirely. MUI’sTabPanelhandles this if you use their component — but if DensityGate is a custom implementation, you must manage it. -
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>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/playwright2. 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 found3. Add checkAccessibility() call to key page tests: - /dashboard (after full load) - /facilities (after grid renders) - /facilities/new (form page) - Each DensityGate tier on /facilities4. Add a standalone accessibility test file: e2e/accessibility.spec.ts that navigates to every route and runs checkAccessibility()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([]);}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:
| Check | How to verify | WCAG criterion |
|---|---|---|
All IconButtons have aria-label | Search for <IconButton without aria-label | 4.1.2 |
| Text contrast >= 4.5:1 on dark backgrounds | Chrome DevTools > Rendering > CSS contrast | 1.4.3 |
| Large text contrast >= 3:1 | Same tool, text >= 18pt or 14pt bold | 1.4.3 |
| Focus visible on every interactive element | Tab through the page, verify visible ring | 2.4.7 |
| Form fields have labels or aria-label | Inspect each TextField, Select, Checkbox | 1.3.1 |
| Error messages linked via aria-describedby | Submit invalid form, check error association | 1.3.1 |
| DataGrid columns have aria-sort | Click sortable column, inspect header | 4.1.2 |
| DensityGate tabs follow WAI-ARIA Tabs pattern | Keyboard navigate with arrow keys | 2.1.1 |
| Dynamic content has aria-live region | Trigger SWR refresh, check announcements | 4.1.3 |
| Skip navigation link present | Tab once from top of page | 2.4.1 |
| Page has a single h1 | Inspect heading hierarchy | 1.3.1 |
| Color is not the only indicator | Check error states, status badges | 1.4.1 |
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-*andtext-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, andhiddenmanagement. - 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.