作者如何用 Claude Code 構建 14 個符合 WCAG 2.1 Level AA 的 Next.js 應用
How I built 14 accessible Next.js apps with Claude Code
作者在過去六個月使用 Claude Code 構建了 14 個正式環境 Next.js 應用,約 350 個畫面均符合 WCAG 2.1 Level AA,且 axe-core 檢查沒有 critical 或 serious 違規。
AI code generators write broken accessibility by default, but not because LLMs are incapable of writing semantic HTML. The issue is that an LLM does not know when it has produced inaccessible code, and most developers do not verify what it outputs.
I built 14 production Next.js applications with Claude Code over the last six months. Every single one passes WCAG 2.1 Level AA across roughly 350 unique screens, using Next.js 16, Tailwind CSS 4, and shadcn/ui. Every app passes axe-core with zero critical or serious violations.
This worked because I set up a verification workflow that catches Claude's mistakes the same way you would catch a junior engineer's mistakes.
The myth about AI and accessibility
The common claim on developer forums is that AI code generators cannot produce accessible code because LLMs hallucinate ARIA attributes and ignore semantic HTML.
That is half true. LLMs do hallucinate ARIA attributes. Claude has generated role="container" (which does not exist) and aria-pressed="yes" (which should be "true") right in front of me. I have watched it output buttons as divs, form inputs without labels, and contrast ratios failing at 2.1:1.
The flaw in the argument is the conclusion. AI generates broken code, but so do humans. Experienced developers catch their own mistakes because they review their work against a checklist and run automated tooling. Most teams using AI skip both.
The solution is not to stop using AI. It is to enforce a repeatable review discipline.
Step 1: Start with an accessibility-first system prompt
Claude Code follows the priorities defined in your system prompt. If you omit accessibility requirements, the model optimizes for visual layout and generation speed.
I add this block to CLAUDE.md in every project before asking Claude to write code:
# Accessibility requirements (NON-NEGOTIABLE)
Every component you write must pass WCAG 2.1 AA. Specifically:
1. Use semantic HTML before reaching for ARIA. <button> not <div onclick>.
<nav> not <div role="navigation">. <main> not <div id="main">.
2. Every interactive element must work with keyboard alone. Tab to focus,
Enter/Space to activate, Esc to close. Test by mentally walking through
the tab order.
3. Every form input must have an associated <label>. Placeholders are
not labels.
4. Color contrast: 4.5:1 for normal text, 3:1 for large text and UI
components. Use the design tokens from globals.css; do not invent
new colors.
5. Focus indicators must be visible against every background the
component can sit on. Ring-2 with offset, never ring-1 with opacity.
6. Status changes must be announced to screen readers via aria-live
or role="status". Toasts use sonner with role="status".
7. Never use color alone to convey meaning. Always pair color with
icon or text.
8. Modals trap focus, return focus to trigger on close, close on Esc.
Use shadcn Dialog (built on Radix), not custom implementations.
9. Touch targets are at least 44x44px on mobile. Use min-h-11 on tap
targets that look smaller.
10. Test the following before marking anything complete:
- Tab through every interactive element
- Resize browser to 320px wide (no horizontal scroll)
- Toggle dark mode (contrast still passes)
- Run the page through axe DevTools (zero criticals)
Claude still makes mistakes with this in place, but they happen far less often, and the model fixes them faster when reminded of its own rules.
Step 2: Use shadcn/ui as the base instead of custom components
shadcn/ui wraps Radix UI primitives. Radix handles focus management, keyboard navigation, ARIA attributes, and screen reader announcements out of the box.
Whenever a screen needs a button, modal, dropdown, dialog, or popover, use the shadcn implementation. Never let Claude build a custom modal or dropdown from scratch. It will eventually drop the focus trap or miss the aria-expanded attribute.
Step 3: Run axe-core on every screen
After Claude generates a screen, run axe DevTools on the rendered page. It takes a few minutes and catches roughly 60% of automated accessibility issues.
To automate this in your test suite, use @axe-core/playwright:
import { test, expect } from "@playwright/test"
import AxeBuilder from "@axe-core/playwright"
test("dashboard has no a11y violations", async ({ page }) => {
await page.goto("/dashboard")
const results = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"])
.analyze()
expect(results.violations).toEqual([])
})
When this test fails, copy the exact violation details back into Claude Code and tell it to fix them. Claude is bad at preventing violations on the initial generation, but it is reliable at fixing them when given the exact failing rule.
Step 4: Run a manual keyboard test
Static tools miss dynamic interaction bugs. A screen can pass axe-core while remaining unusable without a mouse. For every screen:
- Navigate to the page.
- Press Tab repeatedly through every interactive element.
- Check that the tab order follows the visual reading order.
- Trigger actions using only the keyboard: Enter or Space for buttons, arrow keys for menus, Esc to dismiss overlays.
- Watch for elements that trap focus inappropriately or drop focus entirely.
This takes about 90 seconds per view and catches broken dialog traps or menus that fail to focus their first item on open.
Step 5: Run a screen reader smoke test
Turn on VoiceOver on macOS (Cmd+F5) or NVDA on Windows and walk the main user flow. Check three things:
- Meaningful labels: every interactive item announces a clear label, not just "button" or "edit text".
- Dynamic updates: loading spinners, form error states, and success toasts trigger announcements.
- Headings: the hierarchy is logical, starting with one
h1, followed byh2sections, without skipped levels.
Step 6: Test color contrast across all themes
shadcn relies on CSS custom properties for theming. A color pair that passes contrast in light mode can easily fail in dark mode.
The most frequent regression is muted-foreground placed on top of muted backgrounds. Check every theme permutation explicitly.
Common Claude mistakes and the fix prompts
Across hundreds of generated screens, Claude repeatedly falls into the same specific traps:
1. Clickable divs
Claude will build a card as a <div> with an onClick handler. It looks right visually, but screen readers and keyboards ignore it.
Prompt: "Make every clickable card a <button> or wrap it in a <Link> from next/link, never a div with onClick."
2. Skipped heading levels
Claude often puts an <h1> on the title and jumps immediately to <h3> for cards to match visual styling, violating WCAG 1.3.1.
Prompt: "Heading levels must be sequential. After h1, the next level can only be h2. After h2, only h2 or h3. Never skip a level."
3. Placeholders instead of labels
Inputs will get placeholder="Email address" with no <label> element. Once the user types, the placeholder disappears, and screen readers lose context.
Prompt: "Every form input must have a <label> element associated via htmlFor. Use the shadcn <Label> component. The placeholder is for example text only, never as the label."
4. Custom modals without focus traps
If Claude writes a modal without Radix primitives, tabbing escapes the overlay into background elements.
Prompt: "Always use the shadcn <Dialog> component for modals. Never build a custom modal. Radix Dialog handles focus trap, focus return, and Esc-to-close correctly."
5. Silent asynchronous updates
Claude generates a "Saving..." text label on submit, but omits an announcement region.
Prompt: "Status messages must be in a region with role='status' or aria-live='polite' so screen readers announce them. For toast notifications, use sonner which handles this automatically."
6. Relying purely on color
Inputs fail with a red border alone, without an error message or icon, which violates WCAG 1.4.1.
Prompt: "Status indicators must combine color, icon, and text. Failed inputs need a red border AND an error message AND an aria-invalid attribute. Successful submits need a green icon AND text AND a status announcement."
7. Hallucinated ARIA
Claude writes attributes that do not exist in the WAI-ARIA specification, such as aria-tooltip or aria-hidden="yes".
Prompt: "Pay especially close attention to attribute names and boolean values. Ensure every ARIA attribute matches the W3C specification exactly."
What cannot be delegated to Claude
Claude Code handled the bulk of the initial markup, but specific tasks required manual implementation:
- Recharts SVGs: Chart graphics have no accessible fallback out of the box. You must write visually hidden
<table>summaries alongside each chart so screen readers can consume the underlying dataset. - Dynamic announcement copy: Specific announcements like "Filter applied, showing 47 results" get missed by LLMs unless explicitly requested.
- Physical screen reader listening: No automated tool replaces testing an interface with VoiceOver or NVDA to verify the spoken reading order.
This is a shorter version of a guide I wrote at Frontkit Studio. The full post has the complete walkthrough.
來源:DEV Community · dev.to